From 360dea9de99ec86552f1f8af4e58747fa282c6ba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 19:24:10 +0000 Subject: [PATCH 1/6] feat: surface on-chain zaps from the reactions zap button Adds a second row of bitcoin-orange chips to the zap amount popup that opens the existing OnchainZapSendDialog prefilled with the chosen amount and the note's author + zappedEvent. The on-chain stack (kind 8333, OnchainZapSender, Account.sendOnchainZap, OnchainZapSendDialog) already existed end-to-end; only the entry point from the reactions row and the on-chain preset amounts were missing. Settings: - AccountZapPreferencesInternal: new onchainZapAmountChoices (defaults 10k/50k/250k sats); back-compat via kotlinx.serialization defaults - AccountSyncedSettings / AccountSettings / Account.updateZapAmounts: wired through end-to-end alongside the existing Lightning list - UpdateZapAmountDialog: new "Quick On-chain Zap Amounts" section with its own chip list and add-amount field; the existing zap-privacy dropdown stays Lightning-only since on-chain has no zap type Reactions UI: - ZapAmountChoicePopup now collects both choice lists and offers an on-chain row in addition to the Lightning one - New on-chain callback opens OnchainZapSendDialog with the prefilled amount; the existing dialog gains a prefillAmountSats parameter and now reads the on-chain preset list for its own quick-pick chips - Other zap entry points (ReusableZapButton, NestActionBar, FilteredZapAmountChoicePopup) are unchanged: on-chain row defaults off so they keep their existing Lightning-only behavior --- .../vitorpamplona/amethyst/model/Account.kt | 2 + .../amethyst/model/AccountSettings.kt | 9 + .../amethyst/model/AccountSyncedSettings.kt | 8 + .../model/AccountSyncedSettingsInternal.kt | 2 + .../amethyst/ui/note/ReactionsRow.kt | 179 +++++++++++++++--- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 91 ++++++++- .../ui/note/UpdateZapAmountViewModel.kt | 21 +- .../ui/screen/loggedIn/AccountViewModel.kt | 3 +- .../loggedIn/wallet/OnchainZapSendDialog.kt | 9 +- amethyst/src/main/res/values/strings.xml | 3 + 10 files changed, 291 insertions(+), 36 deletions(-) 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 79600dd18a..d8307d1687 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -603,12 +603,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 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/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 77f49a84af..e24844d6fa 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,16 @@ fun ZapReaction( ) } + onchainZapRequest?.let { request -> + OnchainZapSendDialog( + accountViewModel = accountViewModel, + onDismiss = { onchainZapRequest = null }, + recipientPubKey = baseNote.author?.pubkeyHex, + zappedEvent = baseNote.toEventHint(), + prefillAmountSats = request.amountSats, + ) + } + if (wantsToSetCustomZap) { ZapCustomDialog( onZapStarts = { zapStartingTime = TimeUtils.now() }, @@ -1834,12 +1858,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 +1895,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 +1907,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 +1934,7 @@ fun ZapAmountChoicePopup( ) { ZapAmountChoicePopupContent( zapAmountChoices = zapAmountChoices, + onchainZapAmountChoices = onchainZapAmountChoices, onZap = { amountInSats -> onZapStarts() accountViewModel.zap( @@ -1905,6 +1951,10 @@ fun ZapAmountChoicePopup( visibilityState.targetState = false }, onChangeAmount = onChangeAmount, + onOnchainAmount = { amount -> + onOnchainAmount(amount) + visibilityState.targetState = false + }, ) } } @@ -1916,6 +1966,8 @@ fun ZapAmountChoicePopupContent( zapAmountChoices: ImmutableList, onZap: (Long) -> Unit, onChangeAmount: () -> Unit, + onchainZapAmountChoices: ImmutableList = persistentListOf(), + onOnchainAmount: (Long?) -> Unit = {}, ) { Box(HalfPadding, contentAlignment = Center) { ElevatedCard( @@ -1923,33 +1975,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 +2077,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/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..85e4782e2f 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 @@ -127,6 +127,7 @@ fun OnchainZapSendDialog( onDismiss: () -> Unit, recipientPubKey: HexKey? = null, zappedEvent: EventHintBundle? = null, + prefillAmountSats: Long? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() @@ -141,7 +142,7 @@ fun OnchainZapSendDialog( var searchInput by remember { mutableStateOf("") } var selectedUser by remember { mutableStateOf(null) } - var amountInput by remember { mutableStateOf("") } + var amountInput by remember { mutableStateOf(prefillAmountSats?.toString().orEmpty()) } var comment by remember { mutableStateOf("") } var feeTier by remember { mutableStateOf(FeeTier.NORMAL) } var fees by remember { mutableStateOf(null) } @@ -155,10 +156,8 @@ fun OnchainZapSendDialog( runCatching { withContext(Dispatchers.IO) { backend.feeEstimates() } }.getOrNull() } - 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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1bc3b6a9db..b66a7902a7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -821,6 +821,9 @@ 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 Zap Privacy Controls how your identity is shown when you send a zap. Connect Wallet From 3ed2245d8ce06e2e418f80ff3dfa8f944f5c5d17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 19:55:15 +0000 Subject: [PATCH 2/6] feat: on-chain zap splits Extends NIP-BC onchain zaps to honor a note's NIP-57 zap-split tags: one Bitcoin transaction pays every pubkey-based recipient atomically, and one kind:8333 receipt is published per recipient (each receipt carries the recipient's pubkey + sat share and shares the same i:). quartz / OnchainZapBuilder - new buildSplit(recipients = listOf(pubkey to sats), ...) produces a PSBT with one output per recipient + optional change output - existing build(...) now delegates to buildSplit; coin selection and change-vs-dust logic are unchanged for the single-recipient path commons / new OnchainZapSplitter - distribute(totalSats, splits, dustThreshold) does the weighted integer-math allocation, dropping the rounding remainder onto the largest-weight recipient first so the per-recipient sats sum exactly to totalSats - throws DustRecipientException if any share lands below dust; the caller surfaces that as a build-stage failure before the tx is built - unit tests cover equal weights, fractional weights, remainder distribution, dust rejection, and input-order preservation commons / OnchainZapSender.sendSplit - mirrors send() but takes the precomputed shares, builds via buildSplit, and publishes N receipts using the same txid; if one receipt publish fails the broadcast txid + already-published receipt ids are surfaced in the Failure result amethyst / Account.sendOnchainZapWithSplits - thin wrapper that hands off to OnchainZapSender.sendSplit using the signer's pubkey amethyst / OnchainZapSendDialog - detects pubkey-based zap splits on the zappedEvent and, when present, defaults to split mode: a SplitsRecipientSection renders one row per recipient with weight % and live per-recipient sats preview - lnAddress-only splits are filtered out (no pubkey -> no Taproot address); a short note tells the user how many recipients were skipped - the send button label switches to "Send X sats, N ways"; an opt-out button lets the user fall back to single-recipient mode - on send: shares are recomputed via OnchainZapSplitter; below-dust configurations surface as a BUILDING-stage failure before signing --- .../vitorpamplona/amethyst/model/Account.kt | 29 ++ .../loggedIn/wallet/OnchainZapSendDialog.kt | 255 +++++++++++++++--- .../commons/onchain/OnchainZapSender.kt | 142 +++++++++- .../commons/onchain/OnchainZapSplitter.kt | 93 +++++++ .../commons/onchain/OnchainZapSplitterTest.kt | 128 +++++++++ .../builder/OnchainZapBuilder.kt | 77 ++++-- 6 files changed, 669 insertions(+), 55 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitter.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitterTest.kt 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, ) From 45aa6044b7cac791d6260b04e8b32690149f3491 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 20:14:44 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20on-chain=20zap=20splits=20=E2=80=94?= =?UTF-8?q?=20drop=20sender=20from=20splits,=20merge=20duplicates,=20gate?= =?UTF-8?q?=20Send=20on=20dust?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit findings from an independent code review: - HIGH: When the user zaps their own post (a common flow), every split that included the post author put the sender on the recipient list, and OnchainZapBuilder.buildSplit refused the whole tx with "cannot zap yourself". Fix: new OnchainZapSplitter.prepare() filters the sender's pubkey out of the splits before they reach the builder. - HIGH: NIP-57 lets the same pubkey appear in zap-split tags more than once (additive weights). buildSplit rejected duplicate recipients. Same prepare() helper merges duplicates by summing weights, in first-seen order. - HIGH: The dialog's live preview only showed amounts for recipients whose share was BELOW dust (because DustRecipientException only carries belowDust). Fix: parent composable computes shares with a zero dust threshold for the preview, gating the Send button on a separate belowDustShares check so the user can see all amounts and can't tap Send into a guaranteed BUILDING-stage failure. - MEDIUM: OnchainZapSendResult.Failure didn't carry the ids of receipts that successfully published before a partial-publish failure. Added publishedReceiptEventIds: List. - LOW: useSplits state was keyed by zappedEvent reference; re-emitted bundles would silently reset the toggle. Now keyed on the event id. Tests added: - splitter: prepare() drops sender, merges duplicates, filters non-positive weights; floating-point weights (0.1 + 0.2) sum exactly - builder: buildSplit produces N recipient outputs + 1 change at index N, conserves sats, rejects duplicates and below-dust shares - sender: sendSplit publishes one receipt per recipient sharing the txid with correct per-recipient amount; partial-publish failure carries the broadcast txid and the ids of receipts that did publish --- .../loggedIn/wallet/OnchainZapSendDialog.kt | 110 +++++++++++------- .../commons/onchain/OnchainZapSender.kt | 7 ++ .../commons/onchain/OnchainZapSplitter.kt | 25 ++++ .../commons/onchain/OnchainZapSenderTest.kt | 82 +++++++++++++ .../commons/onchain/OnchainZapSplitterTest.kt | 40 +++++++ .../builder/OnchainZapBuilderTest.kt | 83 +++++++++++++ 6 files changed, 303 insertions(+), 44 deletions(-) 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 5129f487b7..6471888782 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 @@ -70,6 +70,7 @@ 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 @@ -161,24 +162,25 @@ fun OnchainZapSendDialog( // 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. + // 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(zappedEvent) { - zappedEvent - ?.event - ?.zapSplitSetup() - .orEmpty() - .filterIsInstance() + remember(zappedEventId, senderPubKey) { + val raw = rawSplits.filterIsInstance().map { it.pubKeyHex to it.weight } + OnchainZapSplitter.prepare(raw, senderPubKey) } val skippedLnSplits = - remember(zappedEvent) { - zappedEvent - ?.event - ?.zapSplitSetup() - .orEmpty() - .filterIsInstance() + remember(zappedEventId) { + rawSplits.filterIsInstance() } - var useSplits by remember(zappedEvent) { mutableStateOf(onchainSplits.isNotEmpty()) } + var useSplits by remember(zappedEventId) { mutableStateOf(onchainSplits.isNotEmpty()) } val splitMode = useSplits && onchainSplits.isNotEmpty() LaunchedEffect(Unit) { @@ -202,13 +204,51 @@ 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; the dust offenders are flagged separately + // and gate the Send button. + val previewShares = + remember(splitMode, onchainSplits, amountSats) { + if (!splitMode || amountSats == null || amountSats <= 0) { + null + } else { + runCatching { + OnchainZapSplitter.distribute( + totalSats = amountSats, + splits = onchainSplits, + dustThresholdSats = OnchainZapBuilder.DUST_THRESHOLD_SATS, + ) + }.getOrElse { e -> + if (e is DustRecipientException) { + // Re-run with a 0 dust threshold to get the full shape + // for the preview; the real send still uses the proper + // dust check via [DustRecipientException]. + runCatching { + OnchainZapSplitter.distribute( + totalSats = amountSats, + splits = onchainSplits, + dustThresholdSats = 0L, + ) + }.getOrNull() + } else { + null + } + } + } + } + val belowDustShares = + previewShares.orEmpty().filter { it.sats < OnchainZapBuilder.DUST_THRESHOLD_SATS } + val canSend = !sending && result == null && (splitMode || resolvedRecipient != null) && amountSats != null && amountSats > 0 && - fees != null + fees != null && + (!splitMode || (previewShares != null && belowDustShares.isEmpty())) ModalBottomSheet( onDismissRequest = { if (!sending) onDismiss() }, @@ -261,8 +301,8 @@ fun OnchainZapSendDialog( if (splitMode) { SplitsRecipientSection( splits = onchainSplits, + previewShares = previewShares, skippedLnSplits = skippedLnSplits, - amountSats = amountSats, onDisable = { useSplits = false }, accountViewModel = accountViewModel, ) @@ -345,7 +385,7 @@ fun OnchainZapSendDialog( try { OnchainZapSplitter.distribute( totalSats = amount, - splits = onchainSplits.map { it.pubKeyHex to it.weight }, + splits = onchainSplits, dustThresholdSats = OnchainZapBuilder.DUST_THRESHOLD_SATS, ) } catch (e: DustRecipientException) { @@ -698,32 +738,15 @@ private fun SendButton( @Composable private fun SplitsRecipientSection( - splits: List, + splits: List>, + previewShares: 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 - } - } - } + val totalWeight = splits.sumOf { it.second } Surface( shape = MaterialTheme.shapes.medium, @@ -731,21 +754,21 @@ private fun SplitsRecipientSection( modifier = Modifier.fillMaxWidth(), ) { Column(modifier = Modifier.padding(vertical = 4.dp)) { - splits.forEach { split -> - val share = shares?.firstOrNull { it.recipientPubKey == split.pubKeyHex } + splits.forEach { (pubKey, weight) -> + val share = previewShares?.firstOrNull { it.recipientPubKey == pubKey } Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { UserPicture( - userHex = split.pubKeyHex, + userHex = pubKey, size = 28.dp, accountViewModel = accountViewModel, nav = EmptyNav(), ) Spacer(Modifier.size(8.dp)) Text( - text = "${formatWeight(split.weight, splits)}", + text = formatWeight(weight, totalWeight), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), @@ -789,10 +812,9 @@ private fun SplitsRecipientSection( private fun formatWeight( weight: Double, - all: List, + totalWeight: Double, ): String { - val total = all.sumOf { it.weight } - val pct = (weight / total) * 100.0 + val pct = (weight / totalWeight) * 100.0 return if (pct >= 99.95) { "100%" } else { 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 12c05a958e..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 @@ -87,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 } @@ -359,6 +365,7 @@ object OnchainZapSender { "but the next receipt could not be published", cause = e, broadcastTxid = txid, + publishedReceiptEventIds = publishedIds.toList(), ) } } 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 index 01e76462d1..6ee96927c5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitter.kt @@ -50,6 +50,31 @@ class DustRecipientException( * - 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>, 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 index c82275a303..198204d77c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitterTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitterTest.kt @@ -111,6 +111,33 @@ class OnchainZapSplitterTest { 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 = @@ -125,4 +152,17 @@ class OnchainZapSplitterTest { assertTrue(shares[1].sats in 29_900..30_100) assertTrue(shares[2].sats in 19_900..20_100) } + + @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/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, + ) + } + } } From a90dd47ed48493fef6149ef72c416b4519858766 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 20:20:21 +0000 Subject: [PATCH 4/6] feat: on-chain option on the Zap the Devs button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReusableZapButton now passes the user's on-chain zap amount choices to ZapAmountChoicePopup and renders OnchainZapSendDialog when a chip is tapped. The dialog receives the release note as the zappedEvent, so the existing split detection picks up the kind:1 release notes' zap splits and pays the dev team via one Bitcoin tx with N receipts. The donation card is the canonical multi-recipient on-chain zap flow — release notes are tagged with weighted splits across the team, the sender's own pubkey gets filtered out, and the per-recipient share preview shows live as the amount is typed. Other callers of ReusableZapButton (DVM zap buttons, etc.) get the on-chain row automatically since it's driven by the user's settings; empty on-chain amounts list hides the row, matching prior behavior. --- .../ui/components/ReusableZapButton.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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..44e5591c04 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,25 @@ fun ReusableZapButton( nav.nav(Route.ManualZapSplitPayment(uid)) } }, + onchainZapAmountChoices = onchainZapAmountChoices, + onOnchainAmount = { amount -> + wantsToZap = null + onchainZapAmount = amount + showOnchainDialog = true + }, + ) + } + + if (showOnchainDialog) { + OnchainZapSendDialog( + accountViewModel = accountViewModel, + onDismiss = { + showOnchainDialog = false + onchainZapAmount = null + }, + recipientPubKey = baseNote.author?.pubkeyHex, + zappedEvent = baseNote.toEventHint(), + prefillAmountSats = onchainZapAmount, ) } From 85dfe93ea75cf1e3c3f3a30359def5ad74ae1faa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 20:35:01 +0000 Subject: [PATCH 5/6] feat: on-chain handoff from the custom-zap dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Send on-chain instead" link button at the bottom of ZapCustomDialog. Tapping it opens OnchainZapSendDialog with the entered amount and message prefilled, the note as zappedEvent, and the same split-detection / dust-preview / fee-tier picker that the Reactions zap chip uses. This also fixes the participant zap path in nests audio rooms (ParticipantHostActionsSheet long-press → "Zap") since that menu item opens ZapCustomDialog under the hood — fixing the custom dialog covers both entry points transitively. OnchainZapSendDialog gains a `prefillComment: String = ""` parameter so the message field carries over from the LN dialog. Poll-note zaps (FilteredZapAmountChoicePopup) intentionally stay Lightning-only — the poll_option vote is encoded in the kind:9734 zap request and counted via kind:9735 receipts; kind:8333 on-chain receipts have no poll_option analog and the count infrastructure doesn't index them. This is a protocol design constraint, not a UI oversight. Coverage audit summary — every user-facing zap initiation point now has on-chain support except the poll-voting path: - ZapReaction (reactions row) ✅ - ZapAmountChoicePopup (popup chips) ✅ - ZapCustomDialog (custom amount + message) ✅ (this commit) - ReusableZapButton (Zap the Devs + DVM buttons) ✅ - NestActionBar (audio room) ✅ - ParticipantHostActionsSheet (audio room participant) ✅ (transitive) - ChatMessageCompose / live-activity headers (use ZapReaction) ✅ - FilteredZapAmountChoicePopup (poll votes) ⚠️ LN-only by protocol --- .../amethyst/ui/note/ZapCustomDialog.kt | 37 +++++++++++++++++++ .../loggedIn/wallet/OnchainZapSendDialog.kt | 3 +- amethyst/src/main/res/values/strings.xml | 1 + 3 files changed, 40 insertions(+), 1 deletion(-) 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..befdaa60e5 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,38 @@ 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) { + OnchainZapSendDialog( + accountViewModel = accountViewModel, + onDismiss = { + sendOnchain = false + onClose() + }, + recipientPubKey = baseNote.author?.pubkeyHex, + zappedEvent = baseNote.toEventHint(), + prefillAmountSats = postViewModel.value(), + prefillComment = postViewModel.customMessage.text, + ) + } } @Composable 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 6471888782..656df4d944 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 @@ -137,6 +137,7 @@ fun OnchainZapSendDialog( recipientPubKey: HexKey? = null, zappedEvent: EventHintBundle? = null, prefillAmountSats: Long? = null, + prefillComment: String = "", ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() @@ -152,7 +153,7 @@ fun OnchainZapSendDialog( var searchInput by remember { mutableStateOf("") } var selectedUser by remember { mutableStateOf(null) } var amountInput by remember { mutableStateOf(prefillAmountSats?.toString().orEmpty()) } - var comment by remember { mutableStateOf("") } + var comment by remember { mutableStateOf(prefillComment) } var feeTier by remember { mutableStateOf(FeeTier.NORMAL) } var fees by remember { mutableStateOf(null) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b66a7902a7..bcd67af071 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -824,6 +824,7 @@ 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 From f6db678249090ce32434d1be527b171ba4f64df5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 20:49:33 +0000 Subject: [PATCH 6/6] fix: audit follow-ups (fee retry, perf, self-pay gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From an independent audit + my own pass, addressing concrete issues: OnchainZapSendDialog - Fee estimate fetch now retries with bounded backoff (4 tries, 1/2/3s spacing) instead of giving up after one attempt. Covers two real boot races: LocalCache.onchainBackend not yet wired at first composition, and a flaky feeEstimates() call. Without retry the Send button stayed permanently disabled. - SplitsRecipientSection now indexes preview shares by pubkey once via remember(previewShares) { associateBy { ... } } instead of an O(N²) firstOrNull lookup per split row. - belowDustShares is now wrapped in remember(previewShares) so it doesn't re-filter the list on every recomposition. - canSend now also requires resolvedRecipient != senderPubKey in single-recipient mode, so the user can't tap Send when the only fallback recipient is themselves (would fail at the builder's "cannot zap yourself" check). - formatWeight no longer prints "50.0%" for whole-percent shares — trailing ".0" is stripped (was a Double->String artifact). OnchainZapSplitter - Added distributeUnchecked(): same allocation as distribute() but never throws on dust; returns every share so the UI preview can render the full shape in one pass. distribute() (used by the build/send path) still throws via DustRecipientException so the real send keeps its dust gate. - Added check(remainder < splits.size) before the remainder loop to pin the invariant that bounds remainder.toInt() and the k % size defensive mod. - Test for distributeUnchecked. ReactionsRow / ReusableZapButton / ZapCustomDialog - baseNote.toEventHint() is now wrapped in remember(baseNote) in all three dialog launchers so it's not allocated on every parent recomposition. --- .../ui/components/ReusableZapButton.kt | 3 +- .../amethyst/ui/note/ReactionsRow.kt | 3 +- .../amethyst/ui/note/ZapCustomDialog.kt | 3 +- .../loggedIn/wallet/OnchainZapSendDialog.kt | 70 ++++++++++--------- .../commons/onchain/OnchainZapSplitter.kt | 36 ++++++++-- .../commons/onchain/OnchainZapSplitterTest.kt | 15 ++++ 6 files changed, 88 insertions(+), 42 deletions(-) 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 44e5591c04..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 @@ -211,6 +211,7 @@ fun ReusableZapButton( } if (showOnchainDialog) { + val zappedEventHint = remember(baseNote) { baseNote.toEventHint() } OnchainZapSendDialog( accountViewModel = accountViewModel, onDismiss = { @@ -218,7 +219,7 @@ fun ReusableZapButton( onchainZapAmount = null }, recipientPubKey = baseNote.author?.pubkeyHex, - zappedEvent = baseNote.toEventHint(), + 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 e24844d6fa..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 @@ -1245,11 +1245,12 @@ fun ZapReaction( } onchainZapRequest?.let { request -> + val zappedEventHint = remember(baseNote) { baseNote.toEventHint() } OnchainZapSendDialog( accountViewModel = accountViewModel, onDismiss = { onchainZapRequest = null }, recipientPubKey = baseNote.author?.pubkeyHex, - zappedEvent = baseNote.toEventHint(), + zappedEvent = zappedEventHint, prefillAmountSats = request.amountSats, ) } 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 befdaa60e5..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 @@ -354,6 +354,7 @@ fun ZapCustomDialog( } if (sendOnchain) { + val zappedEventHint = remember(baseNote) { baseNote.toEventHint() } OnchainZapSendDialog( accountViewModel = accountViewModel, onDismiss = { @@ -361,7 +362,7 @@ fun ZapCustomDialog( onClose() }, recipientPubKey = baseNote.author?.pubkeyHex, - zappedEvent = baseNote.toEventHint(), + zappedEvent = zappedEventHint, prefillAmountSats = postViewModel.value(), prefillComment = postViewModel.customMessage.text, ) 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 656df4d944..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 @@ -93,6 +93,7 @@ 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 @@ -184,10 +185,24 @@ fun OnchainZapSendDialog( 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 by accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices @@ -208,44 +223,28 @@ fun OnchainZapSendDialog( // 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; the dust offenders are flagged separately - // and gate the Send button. + // 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.distribute( - totalSats = amountSats, - splits = onchainSplits, - dustThresholdSats = OnchainZapBuilder.DUST_THRESHOLD_SATS, - ) - }.getOrElse { e -> - if (e is DustRecipientException) { - // Re-run with a 0 dust threshold to get the full shape - // for the preview; the real send still uses the proper - // dust check via [DustRecipientException]. - runCatching { - OnchainZapSplitter.distribute( - totalSats = amountSats, - splits = onchainSplits, - dustThresholdSats = 0L, - ) - }.getOrNull() - } else { - null - } - } + OnchainZapSplitter.distributeUnchecked(amountSats, onchainSplits) + }.getOrNull() } } val belowDustShares = - previewShares.orEmpty().filter { it.sats < OnchainZapBuilder.DUST_THRESHOLD_SATS } + remember(previewShares) { + previewShares.orEmpty().filter { it.sats < OnchainZapBuilder.DUST_THRESHOLD_SATS } + } val canSend = !sending && result == null && - (splitMode || resolvedRecipient != null) && + (splitMode || (resolvedRecipient != null && resolvedRecipient != senderPubKey)) && amountSats != null && amountSats > 0 && fees != null && @@ -748,6 +747,12 @@ private fun SplitsRecipientSection( 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, @@ -756,7 +761,7 @@ private fun SplitsRecipientSection( ) { Column(modifier = Modifier.padding(vertical = 4.dp)) { splits.forEach { (pubKey, weight) -> - val share = previewShares?.firstOrNull { it.recipientPubKey == pubKey } + val share = previewByPubKey?.get(pubKey) Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, @@ -819,9 +824,10 @@ private fun formatWeight( 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%" + // 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}%" } } 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 index 6ee96927c5..553bdf0b9b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitter.kt @@ -79,6 +79,27 @@ object OnchainZapSplitter { 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" } @@ -96,23 +117,24 @@ object OnchainZapSplitter { 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 } } - 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 + 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/OnchainZapSplitterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitterTest.kt index 198204d77c..bf6bc1d461 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitterTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSplitterTest.kt @@ -153,6 +153,21 @@ class OnchainZapSplitterTest { 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