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 95c96db8f4..981d0b7392 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 @@ -603,12 +604,14 @@ class Account( suspend fun updateZapAmounts( amountSet: List, + onchainAmountSet: List, selectedZapType: LnZapEvent.ZapType, nip47Update: Nip47WalletConnect.Nip47URINorm?, ) { var changed = false if (settings.changeZapAmounts(amountSet)) changed = true + if (settings.changeOnchainZapAmounts(onchainAmountSet)) changed = true if (settings.changeDefaultZapType(selectedZapType)) changed = true if (settings.changeZapPaymentRequest(nip47Update)) changed = true @@ -799,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/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 07ae07b865..6d80a12bc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -267,6 +267,15 @@ class AccountSettings( return false } + fun changeOnchainZapAmounts(newAmounts: List): Boolean { + if (syncedSettings.zaps.onchainZapAmountChoices.value != newAmounts) { + syncedSettings.zaps.onchainZapAmountChoices.tryEmit(newAmounts.toImmutableList()) + saveAccountSettings() + return true + } + return false + } + fun changeReactionTypes(newTypes: List): Boolean { if (syncedSettings.reactions.reactionChoices.value != newTypes) { syncedSettings.reactions.reactionChoices.tryEmit(newTypes.toImmutableList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index ca5b08b360..c8fa71e767 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -40,6 +40,7 @@ class AccountSyncedSettings( val zaps = AccountZapPreferences( MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()), + MutableStateFlow(internalSettings.zaps.onchainZapAmountChoices.toImmutableList()), MutableStateFlow(internalSettings.zaps.defaultZapType), ) val languages = @@ -69,6 +70,7 @@ class AccountSyncedSettings( zaps = AccountZapPreferencesInternal( zaps.zapAmountChoices.value, + zaps.onchainZapAmountChoices.value, zaps.defaultZapType.value, ), languages = @@ -107,6 +109,11 @@ class AccountSyncedSettings( zaps.zapAmountChoices.tryEmit(newZapChoices) } + val newOnchainZapChoices = syncedSettingsInternal.zaps.onchainZapAmountChoices.toImmutableList() + if (!equalImmutableLists(zaps.onchainZapAmountChoices.value, newOnchainZapChoices)) { + zaps.onchainZapAmountChoices.tryEmit(newOnchainZapChoices) + } + if (zaps.defaultZapType.value != syncedSettingsInternal.zaps.defaultZapType) { zaps.defaultZapType.tryEmit(syncedSettingsInternal.zaps.defaultZapType) } @@ -175,6 +182,7 @@ class AccountVideoPlayerPreferences( @Stable class AccountZapPreferences( var zapAmountChoices: MutableStateFlow>, + var onchainZapAmountChoices: MutableStateFlow>, val defaultZapType: MutableStateFlow, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 29ebf1bbc4..c411336809 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -38,6 +38,7 @@ val DefaultReactions = ) val DefaultZapAmounts = listOf(100L, 500L, 1000L) +val DefaultOnchainZapAmounts = listOf(10_000L, 50_000L, 250_000L) val DefaultReportWarningThreshold = 5 @Serializable @@ -153,6 +154,7 @@ class AccountReactionPreferencesInternal( @Serializable class AccountZapPreferencesInternal( var zapAmountChoices: List = DefaultZapAmounts, + var onchainZapAmountChoices: List = DefaultOnchainZapAmounts, val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt index 7e74f2a3fd..772948a63d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -59,11 +60,13 @@ import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.note.ZappedIcon import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp import com.vitorpamplona.amethyst.ui.theme.Size14Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -104,6 +107,12 @@ fun ReusableZapButton( callbacks: ZapButtonCallbacks = ZapButtonCallbacks(), ) { var wantsToZap by remember { mutableStateOf?>(null) } + var onchainZapAmount by remember { mutableStateOf(null) } + var showOnchainDialog by remember { mutableStateOf(false) } + + val onchainZapAmountChoices by + accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices + .collectAsStateWithLifecycle() // Makes sure the user is loaded to get his ln address ahead of time (for DVM buttons) if (config.showUserFinderSubscription) { @@ -192,6 +201,26 @@ fun ReusableZapButton( nav.nav(Route.ManualZapSplitPayment(uid)) } }, + onchainZapAmountChoices = onchainZapAmountChoices, + onOnchainAmount = { amount -> + wantsToZap = null + onchainZapAmount = amount + showOnchainDialog = true + }, + ) + } + + if (showOnchainDialog) { + val zappedEventHint = remember(baseNote) { baseNote.toEventHint() } + OnchainZapSendDialog( + accountViewModel = accountViewModel, + onDismiss = { + showOnchainDialog = false + onchainZapAmount = null + }, + recipientPubKey = baseNote.author?.pubkeyHex, + zappedEvent = zappedEventHint, + prefillAmountSats = onchainZapAmount, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 77f49a84af..842f23f220 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -136,6 +136,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTargetsDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -172,6 +173,7 @@ import com.vitorpamplona.amethyst.ui.theme.reactionBox import com.vitorpamplona.amethyst.ui.theme.ripple24dp import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @@ -1121,6 +1123,11 @@ private fun likeClick( } } +@Immutable +private data class OnchainZapRequest( + val amountSats: Long?, +) + @Composable @OptIn(ExperimentalFoundationApi::class, ExperimentalUuidApi::class) fun ZapReaction( @@ -1135,6 +1142,9 @@ fun ZapReaction( ) { var wantsToZap by remember { mutableStateOf(false) } var wantsToSetCustomZap by remember { mutableStateOf(false) } + // null = closed; OnchainZapRequest(amount=null) = open with no prefill; + // OnchainZapRequest(amount=N) = open prefilled to N sats. + var onchainZapRequest by remember { mutableStateOf(null) } val context = LocalContext.current val scope = rememberCoroutineScope() @@ -1207,6 +1217,10 @@ fun ZapReaction( nav.nav(Route.UpdateZapAmount()) } }, + onOnchainAmount = { amount -> + wantsToZap = false + onchainZapRequest = OnchainZapRequest(amount) + }, onError = { _, message, user -> scope.launch { zappingProgress = 0f @@ -1230,6 +1244,17 @@ fun ZapReaction( ) } + onchainZapRequest?.let { request -> + val zappedEventHint = remember(baseNote) { baseNote.toEventHint() } + OnchainZapSendDialog( + accountViewModel = accountViewModel, + onDismiss = { onchainZapRequest = null }, + recipientPubKey = baseNote.author?.pubkeyHex, + zappedEvent = zappedEventHint, + prefillAmountSats = request.amountSats, + ) + } + if (wantsToSetCustomZap) { ZapCustomDialog( onZapStarts = { zapStartingTime = TimeUtils.now() }, @@ -1834,12 +1859,29 @@ fun ZapAmountChoicePopup( onError: (title: String, text: String, user: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, + onOnchainAmount: ((Long?) -> Unit)? = null, ) { val zapAmountChoices by accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices .collectAsStateWithLifecycle() + val onchainZapAmountChoices by + accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices + .collectAsStateWithLifecycle() - ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, onZapStarts, onDismiss, onChangeAmount, onError, onProgress, onPayViaIntent) + ZapAmountChoicePopup( + baseNote = baseNote, + zapAmountChoices = zapAmountChoices, + accountViewModel = accountViewModel, + popupYOffset = popupYOffset, + onZapStarts = onZapStarts, + onDismiss = onDismiss, + onChangeAmount = onChangeAmount, + onError = onError, + onProgress = onProgress, + onPayViaIntent = onPayViaIntent, + onchainZapAmountChoices = if (onOnchainAmount != null) onchainZapAmountChoices else persistentListOf(), + onOnchainAmount = onOnchainAmount ?: {}, + ) } @Composable @@ -1854,9 +1896,11 @@ fun ZapAmountChoicePopup( onError: (title: String, text: String, user: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, + onchainZapAmountChoices: ImmutableList = persistentListOf(), + onOnchainAmount: (Long?) -> Unit = {}, ) { val visibilityState = rememberVisibilityState(onDismiss) - ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, visibilityState, onZapStarts, onChangeAmount, onError, onProgress, onPayViaIntent) + ZapAmountChoicePopup(baseNote, zapAmountChoices, onchainZapAmountChoices, accountViewModel, popupYOffset, visibilityState, onZapStarts, onChangeAmount, onOnchainAmount, onError, onProgress, onPayViaIntent) } @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @@ -1864,11 +1908,13 @@ fun ZapAmountChoicePopup( fun ZapAmountChoicePopup( baseNote: Note, zapAmountChoices: ImmutableList, + onchainZapAmountChoices: ImmutableList, accountViewModel: AccountViewModel, popupYOffset: Dp, visibilityState: MutableTransitionState, onZapStarts: () -> Unit, onChangeAmount: () -> Unit, + onOnchainAmount: (Long?) -> Unit, onError: (title: String, text: String, user: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, @@ -1889,6 +1935,7 @@ fun ZapAmountChoicePopup( ) { ZapAmountChoicePopupContent( zapAmountChoices = zapAmountChoices, + onchainZapAmountChoices = onchainZapAmountChoices, onZap = { amountInSats -> onZapStarts() accountViewModel.zap( @@ -1905,6 +1952,10 @@ fun ZapAmountChoicePopup( visibilityState.targetState = false }, onChangeAmount = onChangeAmount, + onOnchainAmount = { amount -> + onOnchainAmount(amount) + visibilityState.targetState = false + }, ) } } @@ -1916,6 +1967,8 @@ fun ZapAmountChoicePopupContent( zapAmountChoices: ImmutableList, onZap: (Long) -> Unit, onChangeAmount: () -> Unit, + onchainZapAmountChoices: ImmutableList = persistentListOf(), + onOnchainAmount: (Long?) -> Unit = {}, ) { Box(HalfPadding, contentAlignment = Center) { ElevatedCard( @@ -1923,33 +1976,66 @@ fun ZapAmountChoicePopupContent( elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { - FlowRow( - modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp), - horizontalArrangement = Arrangement.Center, - verticalArrangement = Arrangement.Center, - itemVerticalAlignment = CenterVertically, - ) { - zapAmountChoices.forEach { amountInSats -> - ZapAmountChip( - amountInSats = amountInSats, - onClick = { onZap(amountInSats) }, - onLongClick = onChangeAmount, - ) - } - ClickableBox( - modifier = - Modifier - .padding(horizontal = 4.dp, vertical = 6.dp) - .size(32.dp) - .padding(7.dp), - onClick = onChangeAmount, + Column { + FlowRow( + modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center, + itemVerticalAlignment = CenterVertically, ) { - Icon( - symbol = MaterialSymbols.Tune, - contentDescription = stringRes(R.string.quick_zap_amounts), - modifier = Size18Modifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) + zapAmountChoices.forEach { amountInSats -> + ZapAmountChip( + amountInSats = amountInSats, + onClick = { onZap(amountInSats) }, + onLongClick = onChangeAmount, + ) + } + ClickableBox( + modifier = + Modifier + .padding(horizontal = 4.dp, vertical = 6.dp) + .size(32.dp) + .padding(7.dp), + onClick = onChangeAmount, + ) { + Icon( + symbol = MaterialSymbols.Tune, + contentDescription = stringRes(R.string.quick_zap_amounts), + modifier = Size18Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + } + if (onchainZapAmountChoices.isNotEmpty()) { + FlowRow( + modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center, + itemVerticalAlignment = CenterVertically, + ) { + onchainZapAmountChoices.forEach { amountInSats -> + OnchainZapAmountChip( + amountInSats = amountInSats, + onClick = { onOnchainAmount(amountInSats) }, + onLongClick = onChangeAmount, + ) + } + ClickableBox( + modifier = + Modifier + .padding(horizontal = 4.dp, vertical = 6.dp) + .size(32.dp) + .padding(7.dp), + onClick = { onOnchainAmount(null) }, + ) { + Icon( + symbol = MaterialSymbols.Tune, + contentDescription = stringRes(R.string.quick_zap_amounts_onchain), + modifier = Size18Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + } } } } @@ -1992,14 +2078,52 @@ private fun ZapAmountChip( } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun OnchainZapAmountChip( + amountInSats: Long, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + Surface( + shape = ButtonBorder, + color = BitcoinOrange, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp), + ) { + Row( + modifier = + Modifier + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = null, + modifier = Size18Modifier, + tint = Color.White, + ) + Spacer(Modifier.width(2.dp)) + Text( + text = showAmount(amountInSats.toBigDecimal().setScale(1)), + color = Color.White, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + } + } +} + @Preview @Composable fun ZapAmountChoicePopupPreview() { ThemeComparisonColumn { ZapAmountChoicePopupContent( zapAmountChoices = persistentListOf(50L, 100L, 500L, 1_000L, 5_000L, 10_000L, 100_000L), + onchainZapAmountChoices = persistentListOf(10_000L, 50_000L, 250_000L), onZap = {}, onChangeAmount = {}, + onOnchainAmount = {}, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 571be707fb..d4e5b3c5a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -306,7 +306,96 @@ fun UpdateZapAmountContent( ) } - // ── Section 2: Zap Privacy ──────────────────────────────────────────── + // ── Section 2: Quick On-chain Zap Amounts ───────────────────────────── + + Text( + text = stringRes(R.string.quick_zap_amounts_onchain), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + modifier = SettingsCategorySpacingModifier, + ) + Text( + text = stringRes(R.string.quick_zap_amounts_onchain_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(bottom = 6.dp), + ) + + FlowRow( + modifier = + Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + postViewModel.onchainAmountSet.forEach { amountInSats -> + InputChip( + selected = false, + onClick = { postViewModel.removeOnchainAmount(amountInSats) }, + label = { + Text( + text = "₿ ${showAmount(amountInSats.toBigDecimal().setScale(1))}", + ) + }, + trailingIcon = { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.remove), + modifier = Modifier.size(InputChipDefaults.AvatarSize), + ) + }, + colors = + InputChipDefaults.inputChipColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + labelColor = MaterialTheme.colorScheme.primary, + trailingIconColor = MaterialTheme.colorScheme.primary, + ), + ) + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.new_amount_in_sats_onchain)) }, + value = postViewModel.nextOnchainAmount, + onValueChange = { postViewModel.nextOnchainAmount = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number, + ), + placeholder = { + Text( + text = "10000, 50000, 250000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + IconButton( + onClick = postViewModel::addOnchainAmount, + shape = ButtonBorder, + enabled = postViewModel.nextOnchainAmount.text.isNotBlank(), + ) { + Icon( + symbol = MaterialSymbols.AddCircle, + contentDescription = stringRes(R.string.add), + modifier = Size20Modifier, + ) + } + }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + } + + // ── Section 3: Zap Privacy ──────────────────────────────────────────── Text( text = stringRes(R.string.zap_privacy_section), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt index 8d50017d9a..963e6c2864 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt @@ -41,6 +41,8 @@ class UpdateZapAmountViewModel : ViewModel() { var nextAmount by mutableStateOf(TextFieldValue("")) var amountSet by mutableStateOf(listOf()) + var nextOnchainAmount by mutableStateOf(TextFieldValue("")) + var onchainAmountSet by mutableStateOf(listOf()) var walletConnectRelay by mutableStateOf(TextFieldValue("")) var walletConnectPubkey by mutableStateOf(TextFieldValue("")) var walletConnectSecret by mutableStateOf(TextFieldValue("")) @@ -59,6 +61,7 @@ class UpdateZapAmountViewModel : ViewModel() { fun load() { this.amountSet = accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value + this.onchainAmountSet = accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices.value this.selectedZapType = accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value val nip47 = accountViewModel.account.settings.defaultZapPaymentRequest() @@ -83,6 +86,19 @@ class UpdateZapAmountViewModel : ViewModel() { amountSet = amountSet - amount } + fun addOnchainAmount() { + val newValue = nextOnchainAmount.text.trim().toLongOrNull() + if (newValue != null) { + onchainAmountSet = onchainAmountSet + newValue + } + + nextOnchainAmount = TextFieldValue("") + } + + fun removeOnchainAmount(amount: Long) { + onchainAmountSet = onchainAmountSet - amount + } + fun sendPost() { accountViewModel.launchSigner { sendPostSuspend() @@ -116,13 +132,15 @@ class UpdateZapAmountViewModel : ViewModel() { null } - accountViewModel.account.updateZapAmounts(amountSet, selectedZapType, nip47Update) + accountViewModel.account.updateZapAmounts(amountSet, onchainAmountSet, selectedZapType, nip47Update) nextAmount = TextFieldValue("") + nextOnchainAmount = TextFieldValue("") } fun cancel() { nextAmount = TextFieldValue("") + nextOnchainAmount = TextFieldValue("") } fun hasChanged(): Boolean { @@ -130,6 +148,7 @@ class UpdateZapAmountViewModel : ViewModel() { return ( selectedZapType != accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value || amountSet != accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value || + onchainAmountSet != accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices.value || walletConnectPubkey.text != (defaultUri?.pubKeyHex ?: "") || walletConnectRelay.text != (defaultUri?.relayUri?.url ?: "") || walletConnectSecret.text != (defaultUri?.secret ?: "") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 6044bec697..8c4e6feff4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -48,6 +48,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -81,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer @@ -89,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CancellationException @@ -157,6 +160,11 @@ fun ZapCustomDialog( val presetAmounts = remember(accountViewModel) { accountViewModel.zapAmountChoices() } + // True while the on-chain hand-off sheet is open. Dismissing the on-chain + // sheet closes the whole zap flow — the user has already committed to a + // payment method, no "go back to Lightning" is offered here. + var sendOnchain by remember { mutableStateOf(false) } + Dialog( onDismissRequest = { onClose() }, properties = @@ -326,9 +334,39 @@ fun ZapCustomDialog( ) onClose() } + + // Hand off to the on-chain dialog with the entered amount + + // message prefilled. Disabled while the amount is empty so the + // user can't open a sheet that immediately gates on its own + // empty field. + TextButton( + onClick = { sendOnchain = true }, + enabled = postViewModel.canSend() && !baseNote.isDraft(), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 4.dp), + ) { + Text(text = stringRes(id = R.string.send_onchain_instead)) + } } } } + + if (sendOnchain) { + val zappedEventHint = remember(baseNote) { baseNote.toEventHint() } + OnchainZapSendDialog( + accountViewModel = accountViewModel, + onDismiss = { + sendOnchain = false + onClose() + }, + recipientPubKey = baseNote.author?.pubkeyHex, + zappedEvent = zappedEventHint, + prefillAmountSats = postViewModel.value(), + prefillComment = postViewModel.customMessage.text, + ) + } } @Composable 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 b695b85191..af93b2af10 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 @@ -1236,9 +1236,10 @@ class AccountViewModel( fun updateZapAmounts( amountSet: List, + onchainAmountSet: List, selectedZapType: LnZapEvent.ZapType, nip47Update: Nip47WalletConnect.Nip47URINorm?, - ) = launchSigner { account.updateZapAmounts(amountSet, selectedZapType, nip47Update) } + ) = launchSigner { account.updateZapAmounts(amountSet, onchainAmountSet, selectedZapType, nip47Update) } fun toggleDontTranslateFrom(languageCode: String) = launchSigner { account.toggleDontTranslateFrom(languageCode) } 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 f0738c3956..4f646d6695 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,11 @@ 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.OnchainZapShare +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,9 +86,14 @@ 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 +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.text.NumberFormat @@ -127,6 +137,8 @@ fun OnchainZapSendDialog( onDismiss: () -> Unit, recipientPubKey: HexKey? = null, zappedEvent: EventHintBundle? = null, + prefillAmountSats: Long? = null, + prefillComment: String = "", ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() @@ -141,24 +153,60 @@ fun OnchainZapSendDialog( var searchInput by remember { mutableStateOf("") } var selectedUser by remember { mutableStateOf(null) } - var amountInput by remember { mutableStateOf("") } - var comment by remember { mutableStateOf("") } + var amountInput by remember { mutableStateOf(prefillAmountSats?.toString().orEmpty()) } + var comment by remember { mutableStateOf(prefillComment) } var feeTier by remember { mutableStateOf(FeeTier.NORMAL) } var fees by remember { mutableStateOf(null) } 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. The sender's own pubkey is + // also dropped (zapping your own post is common; the on-chain builder + // refuses self-pays) and duplicate pubkeys are merged. + val senderPubKey = accountViewModel.account.signer.pubKey + val zappedEventId = zappedEvent?.event?.id + val rawSplits = + remember(zappedEventId) { + zappedEvent?.event?.zapSplitSetup().orEmpty() + } + val onchainSplits = + remember(zappedEventId, senderPubKey) { + val raw = rawSplits.filterIsInstance().map { it.pubKeyHex to it.weight } + OnchainZapSplitter.prepare(raw, senderPubKey) + } + val skippedLnSplits = + remember(zappedEventId) { + rawSplits.filterIsInstance() + } + var useSplits by remember(zappedEventId) { mutableStateOf(onchainSplits.isNotEmpty()) } + val splitMode = useSplits && onchainSplits.isNotEmpty() + + // Fetch fee estimates with bounded retry. Covers two boot races: + // - LocalCache.onchainBackend is null briefly while AppModules wires it up + // - feeEstimates() throws on a flaky network + // Without retry, the Send button would stay permanently disabled because + // the build path needs a fee rate. LaunchedEffect(Unit) { - val backend = LocalCache.onchainBackend ?: return@LaunchedEffect - fees = - runCatching { withContext(Dispatchers.IO) { backend.feeEstimates() } }.getOrNull() + repeat(4) { attempt -> + if (fees != null) return@LaunchedEffect + val backend = LocalCache.onchainBackend + if (backend != null) { + val newFees = runCatching { withContext(Dispatchers.IO) { backend.feeEstimates() } }.getOrNull() + if (newFees != null) { + fees = newFees + return@LaunchedEffect + } + } + if (attempt < 3) delay(1_000L * (attempt + 1)) + } } - val presetAmounts = - remember(accountViewModel) { - accountViewModel.zapAmountChoices() - } + val presetAmounts by accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices + .collectAsStateWithLifecycle() // Mirror the dropdown's NIP-05 / Namecoin (.bit) resolution so Send can // enable as soon as the typed name resolves, without forcing the user to @@ -172,13 +220,35 @@ fun OnchainZapSendDialog( ?: searchInput.trim().takeIf { it.isNotEmpty() }?.let { decodePublicKeyAsHexOrNull(it) } ?: nip05Resolved?.pubkeyHex val amountSats = amountInput.trim().toLongOrNull() + + // Preview the per-recipient share allocation. Always compute the full + // list (even when some shares would land below dust) so the UI can show + // every recipient's amount; below-dust offenders are flagged separately + // and gate the Send button so the user can't tap into a guaranteed + // BUILDING-stage failure. + val previewShares = + remember(splitMode, onchainSplits, amountSats) { + if (!splitMode || amountSats == null || amountSats <= 0) { + null + } else { + runCatching { + OnchainZapSplitter.distributeUnchecked(amountSats, onchainSplits) + }.getOrNull() + } + } + val belowDustShares = + remember(previewShares) { + previewShares.orEmpty().filter { it.sats < OnchainZapBuilder.DUST_THRESHOLD_SATS } + } + val canSend = !sending && result == null && - resolvedRecipient != null && + (splitMode || (resolvedRecipient != null && resolvedRecipient != senderPubKey)) && amountSats != null && amountSats > 0 && - fees != null + fees != null && + (!splitMode || (previewShares != null && belowDustShares.isEmpty())) ModalBottomSheet( onDismissRequest = { if (!sending) onDismiss() }, @@ -228,31 +298,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, + previewShares = previewShares, + skippedLnSplits = skippedLnSplits, + 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() @@ -285,20 +373,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, + 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 } @@ -591,6 +705,7 @@ private fun SectionLabel(text: String) { private fun SendButton( enabled: Boolean, amountSats: Long?, + splitWays: Int, onClick: () -> Unit, ) { Button( @@ -608,18 +723,114 @@ 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>, + previewShares: List?, + skippedLnSplits: List, + onDisable: () -> Unit, + accountViewModel: AccountViewModel, +) { + SectionLabel("Splits among ${splits.size} recipients") + + val totalWeight = splits.sumOf { it.second } + // Index the preview by pubkey once — the splits list scan would otherwise + // be O(N²) for the per-row sat amount lookup. + val previewByPubKey = + remember(previewShares) { + previewShares?.associateBy { it.recipientPubKey } + } + + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + splits.forEach { (pubKey, weight) -> + val share = previewByPubKey?.get(pubKey) + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + userHex = pubKey, + size = 28.dp, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + Spacer(Modifier.size(8.dp)) + Text( + text = formatWeight(weight, totalWeight), + 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, + totalWeight: Double, +): String { + val pct = (weight / totalWeight) * 100.0 + return if (pct >= 99.95) { + "100%" + } else { + // Round to a tenth of a percent. Drop the trailing ".0" so whole + // percentages render as "50%" instead of "50.0%". + val tenths = (pct * 10).toLong() + if (tenths % 10 == 0L) "${tenths / 10}%" else "${tenths / 10}.${tenths % 10}%" + } +} + @Composable private fun DoneButton( label: String, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1bc3b6a9db..bcd67af071 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -821,6 +821,10 @@ Advanced: enter connection details manually Quick Zap Amounts Shown when pressing the zap button. Tap an amount to remove it. If you leave it empty, it will open the dialog to insert an amount every time. + Quick On-chain Zap Amounts + On-chain zaps pay miner fees, so amounts are usually larger than Lightning. Tap an amount to remove it. + New on-chain amount in sats + Send on-chain instead Zap Privacy Controls how your identity is shown when you send a zap. Connect Wallet 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..a9953abb6b 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 /** @@ -82,6 +87,12 @@ sealed interface OnchainZapSendResult { val cause: Throwable? = null, /** Non-null when the payment was broadcast but a later stage failed. */ val broadcastTxid: String? = null, + /** + * Receipt ids that DID publish before the failure, in the order they + * were sent. Empty for non-publishing failures and for single-recipient + * publishes that fail on the first receipt. + */ + val publishedReceiptEventIds: List = emptyList(), ) : OnchainZapSendResult } @@ -232,6 +243,142 @@ 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, + publishedReceiptEventIds = publishedIds.toList(), + ) + } + } + + 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..553bdf0b9b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitter.kt @@ -0,0 +1,140 @@ +/* + * 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 { + /** + * Clean raw `["zap", pubkey, relay, weight]` splits for the on-chain path: + * + * 1. drop the sender's own pubkey (the on-chain builder refuses self-pays, + * and a self-share would otherwise abort the whole zap when the user + * zaps their own post — common, since most splits include the author) + * 2. merge duplicates by pubkey, summing their weights (Lightning splits + * can repeat a pubkey; on-chain we want exactly one output per pubkey + * so each recipient gets one receipt with one consolidated amount) + * + * The returned list preserves first-seen input order. + */ + fun prepare( + rawSplits: List>, + senderPubKey: HexKey, + ): List> { + val merged = linkedMapOf() + for ((pubKey, weight) in rawSplits) { + if (pubKey == senderPubKey) continue + if (weight <= 0.0) continue + merged[pubKey] = (merged[pubKey] ?: 0.0) + weight + } + return merged.entries.map { it.key to it.value } + } + + fun distribute( + totalSats: Long, + splits: List>, + dustThresholdSats: Long, + ): List { + val shares = computeShares(totalSats, splits) + val belowDust = shares.filter { it.sats < dustThresholdSats } + if (belowDust.isNotEmpty()) throw DustRecipientException(belowDust, dustThresholdSats) + return shares + } + + /** + * Same allocation as [distribute] but never throws on dust. Returns every + * share (including below-dust ones) so a UI preview can render the full + * shape and the caller can decide what to do with offenders. Use + * [distribute] for the actual send path where below-dust must hard-fail. + */ + fun distributeUnchecked( + totalSats: Long, + splits: List>, + ): List = computeShares(totalSats, splits) + + private fun computeShares( + totalSats: Long, + splits: List>, + ): 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 + } + // Each floor() loses < 1 sat, so the total remainder is strictly less + // than `splits.size` — well within Int range for any plausible N. + val remainder = totalSats - assigned + check(remainder < splits.size) { "remainder $remainder exceeds splits.size ${splits.size}" } + if (remainder > 0) { + val orderByWeight = + splits.indices.sortedWith( + compareByDescending { splits[it].second }.thenBy { it }, + ) + for (k in 0 until remainder.toInt()) { + // remainder < splits.size, so k % size is just k — kept + // defensively in case the bound ever loosens. + shares[orderByWeight[k % orderByWeight.size]] += 1 + } + } + + return splits.mapIndexed { i, (pubKey, weight) -> + OnchainZapShare(recipientPubKey = pubKey, sats = shares[i], weight = weight) + } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt index 85849ff0cd..8f439de480 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt @@ -272,4 +272,86 @@ class OnchainZapSenderTest { assertEquals(OnchainZapSendStage.SIGNING, result.stage) assertEquals(null, backend.broadcastedHex) } + + @Test + fun sendSplitProducesOneReceiptPerRecipient() = + runTest { + val r1 = xOnly("000000000000000000000000000000000000000000000000000000000000000d") + val r2 = xOnly("000000000000000000000000000000000000000000000000000000000000000e") + val r3 = xOnly("000000000000000000000000000000000000000000000000000000000000000f") + val backend = FakeBackend(listOf(Utxo("1".repeat(64), 0, 1_000_000L, 6))) + val publishedReceipts = mutableListOf() + + val result = + OnchainZapSender.sendSplit( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipients = + listOf( + OnchainZapShare(r1, 50_000L, 5.0), + OnchainZapShare(r2, 30_000L, 3.0), + OnchainZapShare(r3, 20_000L, 2.0), + ), + feeRateSatPerVByte = 5.0, + comment = "thanks all", + zappedEvent = null, + ) { template -> + val event = senderSigner.sign(template) + publishedReceipts += event + event + } + + assertIs(result) + assertEquals(3, publishedReceipts.size) + assertEquals(2, result.extraReceiptEventIds.size) + + // All receipts must reference the SAME txid. + val broadcastTxid = BitcoinTransaction.parse(backend.broadcastedHex!!).txid() + assertEquals(broadcastTxid, result.txid) + assertTrue(publishedReceipts.all { it.txid() == broadcastTxid }) + + // Per-recipient receipts carry the right pubkey + sat amount. + val byRecipient = publishedReceipts.associateBy { it.recipient() } + assertEquals(50_000L, byRecipient[r1]?.claimedAmountInSats()) + assertEquals(30_000L, byRecipient[r2]?.claimedAmountInSats()) + assertEquals(20_000L, byRecipient[r3]?.claimedAmountInSats()) + } + + @Test + fun sendSplitPartialPublishFailureKeepsBroadcastedReceiptIds() = + runTest { + val r1 = xOnly("000000000000000000000000000000000000000000000000000000000000000d") + val r2 = xOnly("000000000000000000000000000000000000000000000000000000000000000e") + val r3 = xOnly("000000000000000000000000000000000000000000000000000000000000000f") + val backend = FakeBackend(listOf(Utxo("1".repeat(64), 0, 1_000_000L, 6))) + var calls = 0 + + val result = + OnchainZapSender.sendSplit( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipients = + listOf( + OnchainZapShare(r1, 50_000L, 1.0), + OnchainZapShare(r2, 30_000L, 1.0), + OnchainZapShare(r3, 20_000L, 1.0), + ), + feeRateSatPerVByte = 5.0, + comment = "", + zappedEvent = null, + ) { template -> + calls++ + if (calls == 2) throw RuntimeException("relay rejected receipt") + senderSigner.sign(template) + } + + assertIs(result) + assertEquals(OnchainZapSendStage.PUBLISHING, result.stage) + // Tx is on-chain. + assertTrue(result.broadcastTxid != null) + // Exactly one receipt was published before the failure. + assertEquals(1, result.publishedReceiptEventIds.size) + } } 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..bf6bc1d461 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitterTest.kt @@ -0,0 +1,183 @@ +/* + * 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 prepareDropsSenderAndMergesDuplicates() { + val cleaned = + OnchainZapSplitter.prepare( + rawSplits = listOf(a to 1.0, b to 2.0, a to 1.0, c to 0.0, b to 1.0), + senderPubKey = c, + ) + // sender c is dropped, a is merged (1+1=2), b is merged (2+1=3), c=0 filtered too. + assertEquals(listOf(a to 2.0, b to 3.0), cleaned) + } + + @Test + fun prepareDropsSenderEvenIfOnlyEntry() { + val cleaned = OnchainZapSplitter.prepare(listOf(a to 1.0), senderPubKey = a) + assertTrue(cleaned.isEmpty()) + } + + @Test + fun prepareSkipsNegativeOrZeroWeights() { + val cleaned = + OnchainZapSplitter.prepare( + rawSplits = listOf(a to 1.0, b to 0.0, c to -1.0), + senderPubKey = "deadbeef".repeat(8), + ) + assertEquals(listOf(a to 1.0), cleaned) + } + + @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) + } + + @Test + fun distributeUncheckedReturnsBelowDustShares() { + // 1000 sats split 1:99 → 10 and 990. distribute() throws on the 10; + // distributeUnchecked() returns both, leaving dust handling to caller. + val shares = + OnchainZapSplitter.distributeUnchecked( + totalSats = 1000L, + splits = listOf(a to 1.0, b to 99.0), + ) + assertEquals(2, shares.size) + assertEquals(10L, shares[0].sats) + assertEquals(990L, shares[1].sats) + assertEquals(1000L, shares.sumOf { it.sats }) + } + + @Test + fun floatingPointWeightsSumExactly() { + // 0.1 + 0.2 = 0.30000000000000004 in IEEE-754. Make sure that doesn't + // leak a missing or extra sat. + val shares = + OnchainZapSplitter.distribute( + totalSats = 1_000_000L, + splits = listOf(a to 0.1, b to 0.2), + dustThresholdSats = 330L, + ) + assertEquals(1_000_000L, shares.sumOf { it.sats }) + } +} 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, ) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt index 3de8ede56a..4e8646fb5c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt @@ -196,4 +196,87 @@ class OnchainZapBuilderTest { val result = OnchainZapBuilder.build(senderPubKey, recipientPubKey, 25_000L, 2.0, utxos) assertTrue(result.selectedUtxos.all { it.confirmations > 0 }, "must not select the 0-conf UTXO") } + + @Test + fun buildSplitProducesOneOutputPerRecipientPlusChange() { + // Three distinct recipients derived from low-entropy private keys; not + // for production use but fine for shape assertions. + val r1 = + Secp256k1Instance + .compressedPubKeyFor("000000000000000000000000000000000000000000000000000000000000000b".hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + val r2 = + Secp256k1Instance + .compressedPubKeyFor("000000000000000000000000000000000000000000000000000000000000000c".hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + val r3 = + Secp256k1Instance + .compressedPubKeyFor("000000000000000000000000000000000000000000000000000000000000000d".hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + val utxos = listOf(utxo(1_000_000L, 1)) + val result = + OnchainZapBuilder.buildSplit( + senderPubKey = senderPubKey, + recipients = listOf(r1 to 50_000L, r2 to 30_000L, r3 to 20_000L), + feeRateSatPerVByte = 5.0, + availableUtxos = utxos, + ) + + assertEquals(100_000L, result.recipientSats) + assertTrue(result.changeSats > 0, "should have a change output") + + // Conservation: inputs == sum(outputs) + fee. + val inputSum = result.selectedUtxos.sumOf { it.valueSats } + assertEquals(inputSum, result.recipientSats + result.changeSats + result.feeSats) + + // 3 recipient outputs + 1 change. + val tx = result.psbt.unsignedTx + assertEquals(4, tx.outputs.size) + assertEquals(50_000L, tx.outputs[0].valueSats) + assertEquals(TaprootAddress.scriptPubKeyHexForRecipient(r1).lowercase(), tx.outputs[0].scriptPubKey.toHexKey()) + assertEquals(30_000L, tx.outputs[1].valueSats) + assertEquals(TaprootAddress.scriptPubKeyHexForRecipient(r2).lowercase(), tx.outputs[1].scriptPubKey.toHexKey()) + assertEquals(20_000L, tx.outputs[2].valueSats) + assertEquals(TaprootAddress.scriptPubKeyHexForRecipient(r3).lowercase(), tx.outputs[2].scriptPubKey.toHexKey()) + // Change is always the last output. + assertEquals(result.changeSats, tx.outputs[3].valueSats) + assertEquals(senderScriptHex, tx.outputs[3].scriptPubKey.toHexKey()) + } + + @Test + fun buildSplitRejectsDuplicateRecipients() { + val utxos = listOf(utxo(1_000_000L, 1)) + val ex = + assertFailsWith { + OnchainZapBuilder.buildSplit( + senderPubKey = senderPubKey, + recipients = listOf(recipientPubKey to 10_000L, recipientPubKey to 5_000L), + feeRateSatPerVByte = 5.0, + availableUtxos = utxos, + ) + } + assertTrue(ex.message!!.contains("distinct")) + } + + @Test + fun buildSplitRejectsBelowDustRecipient() { + val r2 = + Secp256k1Instance + .compressedPubKeyFor("000000000000000000000000000000000000000000000000000000000000000c".hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + val utxos = listOf(utxo(1_000_000L, 1)) + assertFailsWith { + OnchainZapBuilder.buildSplit( + senderPubKey = senderPubKey, + recipients = listOf(recipientPubKey to 50_000L, r2 to 100L), + feeRateSatPerVByte = 5.0, + availableUtxos = utxos, + ) + } + } }