diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d8307d1687..8e38556b51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator @@ -801,6 +802,34 @@ class Account( ) { template -> signAndComputeBroadcast(template) } } + /** + * Send a NIP-BC onchain split zap: a single Bitcoin transaction paying + * each recipient their precomputed share, plus one kind:8333 receipt per + * recipient. See [OnchainZapSender.sendSplit] for failure semantics. + */ + suspend fun sendOnchainZapWithSplits( + recipients: List, + feeRateSatPerVByte: Double, + comment: String = "", + zappedEvent: EventHintBundle? = null, + ): OnchainZapSendResult { + val backend = + cache.onchainBackend + ?: return OnchainZapSendResult.Failure( + OnchainZapSendStage.LOADING_UTXOS, + "Bitcoin chain backend is not configured", + ) + return OnchainZapSender.sendSplit( + backend = backend, + signer = signer, + senderPubKey = signer.pubKey, + recipients = recipients, + feeRateSatPerVByte = feeRateSatPerVByte, + comment = comment, + zappedEvent = zappedEvent, + ) { template -> signAndComputeBroadcast(template) } + } + suspend fun report( note: Note, type: ReportType, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index 85e4782e2f..5129f487b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -48,6 +48,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -66,7 +67,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.onchain.DustRecipientException import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSplitter import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow @@ -81,6 +85,10 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup +import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates import com.vitorpamplona.quartz.utils.BigDecimal import kotlinx.coroutines.Dispatchers @@ -150,6 +158,29 @@ fun OnchainZapSendDialog( var sending by remember { mutableStateOf(false) } var result by remember { mutableStateOf(null) } + // Pull pubkey-based zap splits off the zapped event. Lightning-address-only + // splits are dropped because we can't derive a Taproot output from an + // lnAddress — they appear in [skippedLnSplits] so the UI can warn the user + // that those recipients won't be paid on-chain. + val onchainSplits = + remember(zappedEvent) { + zappedEvent + ?.event + ?.zapSplitSetup() + .orEmpty() + .filterIsInstance() + } + val skippedLnSplits = + remember(zappedEvent) { + zappedEvent + ?.event + ?.zapSplitSetup() + .orEmpty() + .filterIsInstance() + } + var useSplits by remember(zappedEvent) { mutableStateOf(onchainSplits.isNotEmpty()) } + val splitMode = useSplits && onchainSplits.isNotEmpty() + LaunchedEffect(Unit) { val backend = LocalCache.onchainBackend ?: return@LaunchedEffect fees = @@ -174,7 +205,7 @@ fun OnchainZapSendDialog( val canSend = !sending && result == null && - resolvedRecipient != null && + (splitMode || resolvedRecipient != null) && amountSats != null && amountSats > 0 && fees != null @@ -227,31 +258,49 @@ fun OnchainZapSendDialog( .verticalScroll(rememberScrollState()) .padding(horizontal = 20.dp), ) { - RecipientSection( - accountViewModel = accountViewModel, - recipientPubKey = recipientPubKey, - userSuggestions = userSuggestions, - selectedUser = selectedUser, - onSelectUser = { - selectedUser = it - searchInput = "" - userSuggestions.reset() - }, - onClearUser = { - selectedUser = null - searchInput = "" - userSuggestions.reset() - }, - searchInput = searchInput, - onSearchChange = { newValue -> - searchInput = newValue - if (newValue.length > 2) { - userSuggestions.processCurrentWord(newValue) - } else { + if (splitMode) { + SplitsRecipientSection( + splits = onchainSplits, + skippedLnSplits = skippedLnSplits, + amountSats = amountSats, + onDisable = { useSplits = false }, + accountViewModel = accountViewModel, + ) + } else { + RecipientSection( + accountViewModel = accountViewModel, + recipientPubKey = recipientPubKey, + userSuggestions = userSuggestions, + selectedUser = selectedUser, + onSelectUser = { + selectedUser = it + searchInput = "" userSuggestions.reset() + }, + onClearUser = { + selectedUser = null + searchInput = "" + userSuggestions.reset() + }, + searchInput = searchInput, + onSearchChange = { newValue -> + searchInput = newValue + if (newValue.length > 2) { + userSuggestions.processCurrentWord(newValue) + } else { + userSuggestions.reset() + } + }, + ) + if (onchainSplits.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + TextButton( + onClick = { useSplits = true }, + ) { + Text("Use this note's ${onchainSplits.size}-way zap split") } - }, - ) + } + } SectionSpacer() @@ -284,20 +333,46 @@ fun OnchainZapSendDialog( SendButton( enabled = canSend, amountSats = amountSats, + splitWays = if (splitMode) onchainSplits.size else 0, onClick = { - val recipient = resolvedRecipient ?: return@SendButton val amount = amountSats ?: return@SendButton val feeRate = fees?.rateFor(feeTier) ?: return@SendButton sending = true scope.launch { val r = - accountViewModel.account.sendOnchainZap( - recipientPubKey = recipient, - amountSats = amount, - feeRateSatPerVByte = feeRate, - comment = comment.trim(), - zappedEvent = zappedEvent, - ) + if (splitMode) { + val shares = + try { + OnchainZapSplitter.distribute( + totalSats = amount, + splits = onchainSplits.map { it.pubKeyHex to it.weight }, + dustThresholdSats = OnchainZapBuilder.DUST_THRESHOLD_SATS, + ) + } catch (e: DustRecipientException) { + sending = false + result = + OnchainZapSendResult.Failure( + stage = OnchainZapSendStage.BUILDING, + message = e.message ?: "A recipient share is below dust", + ) + return@launch + } + accountViewModel.account.sendOnchainZapWithSplits( + recipients = shares, + feeRateSatPerVByte = feeRate, + comment = comment.trim(), + zappedEvent = zappedEvent, + ) + } else { + val recipient = resolvedRecipient ?: return@launch + accountViewModel.account.sendOnchainZap( + recipientPubKey = recipient, + amountSats = amount, + feeRateSatPerVByte = feeRate, + comment = comment.trim(), + zappedEvent = zappedEvent, + ) + } sending = false result = r } @@ -590,6 +665,7 @@ private fun SectionLabel(text: String) { private fun SendButton( enabled: Boolean, amountSats: Long?, + splitWays: Int, onClick: () -> Unit, ) { Button( @@ -607,18 +683,125 @@ private fun SendButton( modifier = Modifier.size(18.dp), ) Spacer(Modifier.size(8.dp)) + val sats = if (amountSats != null && amountSats > 0) NumberFormat.getNumberInstance().format(amountSats) else null Text( text = - if (amountSats != null && amountSats > 0) { - "Send ${NumberFormat.getNumberInstance().format(amountSats)} sats" - } else { - "Send" + when { + sats != null && splitWays > 1 -> "Send $sats sats, $splitWays ways" + sats != null -> "Send $sats sats" + else -> "Send" }, fontWeight = FontWeight.SemiBold, ) } } +@Composable +private fun SplitsRecipientSection( + splits: List, + skippedLnSplits: List, + amountSats: Long?, + onDisable: () -> Unit, + accountViewModel: AccountViewModel, +) { + SectionLabel("Splits among ${splits.size} recipients") + + // Compute per-recipient shares for the live preview. Below-dust splits are + // surfaced inline so the user sees why Send might fail before they tap it. + val shares = + remember(splits, amountSats) { + if (amountSats == null || amountSats <= 0) { + null + } else { + runCatching { + OnchainZapSplitter.distribute( + totalSats = amountSats, + splits = splits.map { it.pubKeyHex to it.weight }, + dustThresholdSats = OnchainZapBuilder.DUST_THRESHOLD_SATS, + ) + }.getOrElse { e -> + if (e is DustRecipientException) e.belowDust else null + } + } + } + + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + splits.forEach { split -> + val share = shares?.firstOrNull { it.recipientPubKey == split.pubKeyHex } + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + userHex = split.pubKeyHex, + size = 28.dp, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + Spacer(Modifier.size(8.dp)) + Text( + text = "${formatWeight(split.weight, splits)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (share != null) { + val belowDust = share.sats < OnchainZapBuilder.DUST_THRESHOLD_SATS + Text( + text = "${NumberFormat.getNumberInstance().format(share.sats)} sats", + style = MaterialTheme.typography.bodySmall, + color = + if (belowDust) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + } + + if (skippedLnSplits.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + val word = if (skippedLnSplits.size == 1) "recipient" else "recipients" + Text( + text = + "Skipping ${skippedLnSplits.size} Lightning-address-only $word — " + + "on-chain needs a Nostr pubkey to derive the address.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onDisable) { + Text("Don't split — pay one recipient instead") + } +} + +private fun formatWeight( + weight: Double, + all: List, +): String { + val total = all.sumOf { it.weight } + val pct = (weight / total) * 100.0 + return if (pct >= 99.95) { + "100%" + } else { + // One decimal place keeps "33.3%" readable without floating-point noise. + val rounded = (pct * 10).toLong() / 10.0 + "$rounded%" + } +} + @Composable private fun DoneButton( label: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt index 2b57828fcb..12c05a958e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt @@ -60,15 +60,20 @@ sealed interface OnchainZapSendResult { * The transaction was broadcast and the zap receipt was published. * * @property txid The broadcast Bitcoin transaction id. - * @property receiptEventId The id of the published kind:8333 event. + * @property receiptEventId The id of the first published kind:8333 event. + * For a split zap, additional receipts (one per recipient) are also + * published; see [extraReceiptEventIds]. * @property feeSats Miner fee paid. * @property changeSats Change returned to the sender (0 if none). + * @property extraReceiptEventIds Receipt ids for the remaining recipients + * when this was a split zap. Empty for a single-recipient zap. */ data class Success( val txid: String, val receiptEventId: HexKey, val feeSats: Long, val changeSats: Long, + val extraReceiptEventIds: List = emptyList(), ) : OnchainZapSendResult /** @@ -232,6 +237,141 @@ object OnchainZapSender { ) } + /** + * Send an onchain split zap: pays N recipients with one Bitcoin transaction + * (one output per recipient) and publishes one kind:8333 receipt per + * recipient, all sharing the same `i ` tag. + * + * Per-recipient shares MUST be precomputed (e.g. via + * [OnchainZapSplitter.distribute]) so the on-chain output amounts and the + * `amount` tags on the receipts agree exactly. + * + * Failure semantics: if any receipt fails to publish, the transaction is + * already on-chain; the result reports the broadcast txid, the ids of any + * receipts that *did* publish, and the publishing-stage failure. + */ + suspend fun sendSplit( + backend: OnchainBackend, + signer: NostrSigner, + senderPubKey: HexKey, + recipients: List, + feeRateSatPerVByte: Double, + comment: String, + zappedEvent: EventHintBundle?, + publish: suspend (EventTemplate) -> Event, + ): OnchainZapSendResult { + require(recipients.isNotEmpty()) { "recipients must be non-empty" } + + // 1. Load the sender's UTXOs. + val utxos = + try { + val address = TaprootAddress.fromPubKey(senderPubKey) + backend.getUtxosForAddress(address) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.LOADING_UTXOS, "Could not load your Bitcoin balance", e) + } + + // 2. Coin-select and assemble the multi-output PSBT. + val built = + try { + OnchainZapBuilder.buildSplit( + senderPubKey = senderPubKey, + recipients = recipients.map { it.recipientPubKey to it.sats }, + feeRateSatPerVByte = feeRateSatPerVByte, + availableUtxos = utxos, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.BUILDING, e.message ?: "Could not build the transaction", e) + } + + // 3. Sign, verify, and finalize. Same fund-safety contract as [send]. + val rawTxHex = + try { + val signedHex = signer.signPsbt(built.psbt.toHex()) + val signedPsbt = Psbt.parse(signedHex) + + val expectedTx = built.psbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX) + val returnedTx = signedPsbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX) + if (expectedTx == null || returnedTx == null || !expectedTx.contentEquals(returnedTx)) { + return fail( + OnchainZapSendStage.SIGNING, + "The signer returned a different transaction than the one it was asked to sign", + ) + } + + built.psbt.unsignedTx.inputs.indices.forEach { i -> + val sig = + signedPsbt.inputTapKeySig(i) + ?: return fail( + OnchainZapSendStage.SIGNING, + "The signer did not sign every input", + ) + built.psbt.setInputTapKeySig(i, sig) + } + + if (!PsbtSignatureVerifier.verifyAllKeyPathInputs(built.psbt)) { + return fail( + OnchainZapSendStage.SIGNING, + "The signed transaction has invalid signatures", + ) + } + PsbtFinalizer.finalizeToHex(built.psbt) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.SIGNING, e.message ?: "Could not sign the transaction", e) + } + + // 4. Broadcast. + val txid = + try { + backend.broadcast(rawTxHex) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.BROADCASTING, "Could not broadcast the transaction", e) + } + + // 5. Publish one receipt per recipient. If any one fails, surface the + // failure but keep the txid + receipts that did publish so the user + // (and any retry tooling) has the full picture. + val publishedIds = ArrayList(recipients.size) + for (share in recipients) { + try { + val template = + if (zappedEvent != null) { + OnchainZapEvent.build(txid, share.recipientPubKey, share.sats, zappedEvent, comment) + } else { + OnchainZapEvent.buildProfileZap(txid, share.recipientPubKey, share.sats, comment) + } + publishedIds.add(publish(template).id) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return OnchainZapSendResult.Failure( + stage = OnchainZapSendStage.PUBLISHING, + message = + "Payment sent (${publishedIds.size} of ${recipients.size} receipts published), " + + "but the next receipt could not be published", + cause = e, + broadcastTxid = txid, + ) + } + } + + return OnchainZapSendResult.Success( + txid = txid, + receiptEventId = publishedIds.first(), + feeSats = built.feeSats, + changeSats = built.changeSats, + extraReceiptEventIds = publishedIds.drop(1), + ) + } + private fun fail( stage: OnchainZapSendStage, message: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitter.kt new file mode 100644 index 0000000000..01e76462d1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitter.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.amethyst.commons.onchain + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** A per-recipient share of an onchain split zap. */ +data class OnchainZapShare( + val recipientPubKey: HexKey, + val sats: Long, + val weight: Double, +) + +/** Thrown when one or more recipient shares fall below the configured dust threshold. */ +class DustRecipientException( + val belowDust: List, + val dustThresholdSats: Long, +) : RuntimeException( + "Recipients below dust threshold ($dustThresholdSats sats): " + + belowDust.joinToString { "${it.recipientPubKey.take(8)}…=${it.sats}" }, + ) + +/** + * Distributes a total amount of sats across weighted recipients using integer + * math, leaving every share `>= dustThresholdSats` or throwing. + * + * Distribution rules: + * - share_i = floor(totalSats * weight_i / totalWeight) + * - the rounding remainder is added one-sat at a time to the recipients with + * the largest weights (largest first; ties broken by input order) so the + * sum of shares equals `totalSats` exactly + * - any share that lands below dust is reported via [DustRecipientException] + */ +object OnchainZapSplitter { + fun distribute( + totalSats: Long, + splits: List>, + dustThresholdSats: Long, + ): List { + require(totalSats > 0) { "total must be positive" } + require(splits.isNotEmpty()) { "splits must be non-empty" } + require(splits.none { it.second <= 0.0 }) { "weights must be positive" } + + val totalWeight = splits.sumOf { it.second } + + // Floor every share, then distribute the leftover one sat at a time in + // descending-weight order — gives the largest recipients the rounding + // benefit and avoids drifting the small ones below dust by accident. + val shares = LongArray(splits.size) + var assigned = 0L + splits.forEachIndexed { i, (_, weight) -> + val s = (totalSats * weight / totalWeight).toLong() + shares[i] = s + assigned += s + } + val remainder = totalSats - assigned + if (remainder > 0) { + val orderByWeight = + splits.indices.sortedWith( + compareByDescending { splits[it].second }.thenBy { it }, + ) + for (k in 0 until remainder.toInt()) { + shares[orderByWeight[k % orderByWeight.size]] += 1 + } + } + + val result = + splits.mapIndexed { i, (pubKey, weight) -> + OnchainZapShare(recipientPubKey = pubKey, sats = shares[i], weight = weight) + } + val belowDust = result.filter { it.sats < dustThresholdSats } + if (belowDust.isNotEmpty()) throw DustRecipientException(belowDust, dustThresholdSats) + return result + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitterTest.kt new file mode 100644 index 0000000000..c82275a303 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitterTest.kt @@ -0,0 +1,128 @@ +/* + * 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.commons.onchain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class OnchainZapSplitterTest { + private val a = "a".repeat(64) + private val b = "b".repeat(64) + private val c = "c".repeat(64) + + @Test + fun equalWeightsSplitEvenly() { + val shares = + OnchainZapSplitter.distribute( + totalSats = 30_000L, + splits = listOf(a to 1.0, b to 1.0, c to 1.0), + dustThresholdSats = 330L, + ) + assertEquals(listOf(10_000L, 10_000L, 10_000L), shares.map { it.sats }) + assertEquals(30_000L, shares.sumOf { it.sats }) + } + + @Test + fun weightedSplitMatchesRatio() { + val shares = + OnchainZapSplitter.distribute( + totalSats = 100_000L, + splits = listOf(a to 3.0, b to 1.0), + dustThresholdSats = 330L, + ) + assertEquals(75_000L, shares[0].sats) + assertEquals(25_000L, shares[1].sats) + assertEquals(100_000L, shares.sumOf { it.sats }) + } + + @Test + fun roundingRemainderGoesToLargestWeight() { + // 10_001 / (2+1+1) = 2500.25 each. Floors: 5000, 2500, 2500 → 2 sats left. + // Both extra sats go to the largest-weight recipient (index 0). + val shares = + OnchainZapSplitter.distribute( + totalSats = 10_001L, + splits = listOf(a to 2.0, b to 1.0, c to 1.0), + dustThresholdSats = 330L, + ) + assertEquals(5001L, shares[0].sats) + assertEquals(2500L, shares[1].sats) + assertEquals(2500L, shares[2].sats) + assertEquals(10_001L, shares.sumOf { it.sats }) + } + + @Test + fun belowDustThrows() { + // 1000 sats split 1:99 → 10 sats and 990 sats. 10 is below 330 dust. + val ex = + assertFailsWith { + OnchainZapSplitter.distribute( + totalSats = 1000L, + splits = listOf(a to 1.0, b to 99.0), + dustThresholdSats = 330L, + ) + } + assertEquals(1, ex.belowDust.size) + assertEquals(a, ex.belowDust[0].recipientPubKey) + } + + @Test + fun preservesInputOrder() { + val shares = + OnchainZapSplitter.distribute( + totalSats = 30_000L, + splits = listOf(c to 1.0, a to 1.0, b to 1.0), + dustThresholdSats = 330L, + ) + assertEquals(c, shares[0].recipientPubKey) + assertEquals(a, shares[1].recipientPubKey) + assertEquals(b, shares[2].recipientPubKey) + } + + @Test + fun singleRecipientGetsEverything() { + val shares = + OnchainZapSplitter.distribute( + totalSats = 50_000L, + splits = listOf(a to 1.0), + dustThresholdSats = 330L, + ) + assertEquals(1, shares.size) + assertEquals(50_000L, shares[0].sats) + } + + @Test + fun fractionalWeightsWork() { + val shares = + OnchainZapSplitter.distribute( + totalSats = 100_000L, + splits = listOf(a to 0.5, b to 0.3, c to 0.2), + dustThresholdSats = 330L, + ) + assertEquals(100_000L, shares.sumOf { it.sats }) + // a:b:c roughly 5:3:2 + assertTrue(shares[0].sats in 49_900..50_100) + assertTrue(shares[1].sats in 29_900..30_100) + assertTrue(shares[2].sats in 19_900..20_100) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt index 64d969cdd0..abe949cae7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt @@ -108,16 +108,54 @@ object OnchainZapBuilder { feeRateSatPerVByte: Double, availableUtxos: List, allowUnconfirmed: Boolean = false, + ): Result = + buildSplit( + senderPubKey = senderPubKey, + recipients = listOf(recipientPubKey to amountSats), + feeRateSatPerVByte = feeRateSatPerVByte, + availableUtxos = availableUtxos, + allowUnconfirmed = allowUnconfirmed, + ) + + /** + * Build the unsigned onchain-zap PSBT with one output per recipient. + * + * Same coin-selection and change-handling as [build]; the only difference + * is that the output set is the full list of (recipient, sats) pairs, so + * a single transaction pays N split recipients atomically. + * + * @param senderPubKey The sender's 32-byte x-only Nostr pubkey (hex). + * @param recipients Per-recipient amount to pay, in input order. + * @param feeRateSatPerVByte Target fee rate. + * @param availableUtxos UTXOs spendable from the sender's Taproot address. + * @param allowUnconfirmed See [build]. + * @throws InsufficientFundsException when the UTXOs can't cover total + fee. + */ + fun buildSplit( + senderPubKey: HexKey, + recipients: List>, + feeRateSatPerVByte: Double, + availableUtxos: List, + allowUnconfirmed: Boolean = false, ): Result { - require(amountSats > 0) { "amount must be positive" } - require(amountSats >= DUST_THRESHOLD_SATS) { "amount is below the dust threshold" } + require(recipients.isNotEmpty()) { "must have at least one recipient" } + require(recipients.all { it.second > 0 }) { "all amounts must be positive" } + require(recipients.all { it.second >= DUST_THRESHOLD_SATS }) { "a recipient amount is below the dust threshold" } require(feeRateSatPerVByte > 0) { "fee rate must be positive" } - require(senderPubKey != recipientPubKey) { "cannot zap yourself" } + require(recipients.none { it.first == senderPubKey }) { "cannot zap yourself" } + // Distinct recipients keep the output set clean and make per-recipient + // receipts unambiguous. Callers should merge weights upstream. + require(recipients.map { it.first }.toSet().size == recipients.size) { + "recipients must be distinct" + } val senderXOnly = senderPubKey.hexToByteArray() require(senderXOnly.size == 32) { "sender pubkey must be 32 bytes" } val senderScript = TaprootAddress.scriptPubKeyForRecipient(senderPubKey) - val recipientScript = TaprootAddress.scriptPubKeyForRecipient(recipientPubKey) + val recipientScripts = + recipients.map { (pubKey, _) -> TaprootAddress.scriptPubKeyForRecipient(pubKey) } + val totalRecipientSats = recipients.sumOf { it.second } + val recipientOutputCount = recipients.size // Only spend confirmed UTXOs unless the caller explicitly opts in. val spendableUtxos = @@ -130,15 +168,15 @@ object OnchainZapBuilder { var cursor = 0 while (true) { - val feeWithChange = estimateFee(selected.size, 2, feeRateSatPerVByte) - if (selected.isNotEmpty() && selectedSum >= amountSats + feeWithChange) break + val feeWithChange = estimateFee(selected.size, recipientOutputCount + 1, feeRateSatPerVByte) + if (selected.isNotEmpty() && selectedSum >= totalRecipientSats + feeWithChange) break if (cursor >= sorted.size) { // Last chance: maybe it fits without a change output. - val feeNoChange = estimateFee(selected.size, 1, feeRateSatPerVByte) - if (selected.isNotEmpty() && selectedSum >= amountSats + feeNoChange) break + val feeNoChange = estimateFee(selected.size, recipientOutputCount, feeRateSatPerVByte) + if (selected.isNotEmpty() && selectedSum >= totalRecipientSats + feeNoChange) break throw InsufficientFundsException( - needed = amountSats + estimateFee(selected.size.coerceAtLeast(1), 2, feeRateSatPerVByte), + needed = totalRecipientSats + estimateFee(selected.size.coerceAtLeast(1), recipientOutputCount + 1, feeRateSatPerVByte), available = spendableUtxos.sumOf { it.valueSats }, ) } @@ -148,8 +186,8 @@ object OnchainZapBuilder { } // Decide whether a change output is worth creating. - val feeWithChange = estimateFee(selected.size, 2, feeRateSatPerVByte) - val candidateChange = selectedSum - amountSats - feeWithChange + val feeWithChange = estimateFee(selected.size, recipientOutputCount + 1, feeRateSatPerVByte) + val candidateChange = selectedSum - totalRecipientSats - feeWithChange val feeSats: Long val changeSats: Long @@ -159,11 +197,11 @@ object OnchainZapBuilder { } else { // Drop the change output; the leftover (dust + would-be change) is // absorbed into the fee. - val feeNoChange = estimateFee(selected.size, 1, feeRateSatPerVByte) - val leftover = selectedSum - amountSats + val feeNoChange = estimateFee(selected.size, recipientOutputCount, feeRateSatPerVByte) + val leftover = selectedSum - totalRecipientSats if (leftover < feeNoChange) { throw InsufficientFundsException( - needed = amountSats + feeNoChange, + needed = totalRecipientSats + feeNoChange, available = spendableUtxos.sumOf { it.valueSats }, ) } @@ -180,8 +218,10 @@ object OnchainZapBuilder { sequence = RBF_SEQUENCE, ) } - val outputs = ArrayList(2) - outputs.add(TxOut(amountSats, recipientScript)) + val outputs = ArrayList(recipientOutputCount + 1) + recipients.forEachIndexed { i, (_, sats) -> + outputs.add(TxOut(sats, recipientScripts[i])) + } if (changeSats > 0) { outputs.add(TxOut(changeSats, senderScript)) } @@ -195,13 +235,14 @@ object OnchainZapBuilder { psbt.setInputTapInternalKey(i, senderXOnly) } if (changeSats > 0) { - psbt.setOutputTapInternalKey(1, senderXOnly) + // Change output is always the last output in the tx. + psbt.setOutputTapInternalKey(recipientOutputCount, senderXOnly) } return Result( psbt = psbt, selectedUtxos = selected, - recipientSats = amountSats, + recipientSats = totalRecipientSats, changeSats = changeSats, feeSats = feeSats, )