refactor(zap): merge on-chain amount presets into the single zap-amount list

The on-chain rail no longer has its own editable preset list. There is now
one zap-amount list used by every rail; on-chain simply filters it by the
shared MIN_ONCHAIN_ZAP_SATS floor (1000 sat), and the unified chip already
gates each logo per amount.

- Settings: drop the public onchainZapAmountChoices StateFlow and its editor
  section in the Update Zap Amount dialog. The serialized field is kept for
  backward/cross-client (NIP-78) compatibility and migration: on load the two
  saved lists are unioned into zapAmountChoices (mergeZapAmounts), and on save
  the on-chain-eligible subset is written back so older clients still get
  sensible on-chain presets. The round-trip is idempotent.
- updateZapAmounts / changeOnchainZapAmounts lose the separate on-chain
  parameter throughout (Account, AccountViewModel, UpdateZapAmountViewModel).
- OnchainZapSendDialog draws its quick presets from the single list filtered
  by the minimum.
- MIN_ONCHAIN_ZAP_SATS is now a single shared constant in the model, replacing
  the copies previously private to OnchainZapSendDialog and ReactionsRow.
- Remove the now-unused on-chain amount strings; clarify the zap-amounts
  explainer to note amounts apply to all rails.

Adds ZapAmountMergeTest covering the union/dedup/idempotent-round-trip.

https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
This commit is contained in:
Claude
2026-05-29 18:28:48 +00:00
parent c1c1a69148
commit 6f7cacf84b
13 changed files with 120 additions and 173 deletions
@@ -652,14 +652,12 @@ class Account(
suspend fun updateZapAmounts(
amountSet: List<Long>,
onchainAmountSet: List<Long>,
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
@@ -291,15 +291,6 @@ class AccountSettings(
return false
}
fun changeOnchainZapAmounts(newAmounts: List<Long>): Boolean {
if (syncedSettings.zaps.onchainZapAmountChoices.value != newAmounts) {
syncedSettings.zaps.onchainZapAmountChoices.tryEmit(newAmounts.toImmutableList())
saveAccountSettings()
return true
}
return false
}
fun changeReactionTypes(newTypes: List<String>): Boolean {
if (syncedSettings.reactions.reactionChoices.value != newTypes) {
syncedSettings.reactions.reactionChoices.tryEmit(newTypes.toImmutableList())
@@ -39,8 +39,7 @@ class AccountSyncedSettings(
)
val zaps =
AccountZapPreferences(
MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()),
MutableStateFlow(internalSettings.zaps.onchainZapAmountChoices.toImmutableList()),
MutableStateFlow(mergeZapAmounts(internalSettings.zaps).toImmutableList()),
MutableStateFlow(internalSettings.zaps.defaultZapType),
)
val languages =
@@ -70,7 +69,10 @@ class AccountSyncedSettings(
zaps =
AccountZapPreferencesInternal(
zaps.zapAmountChoices.value,
zaps.onchainZapAmountChoices.value,
// Write the on-chain-eligible subset into the legacy field so
// older clients still get sensible on-chain presets. Unioning
// it back on load is idempotent (subset ⊆ full list).
zaps.zapAmountChoices.value.filter { it >= MIN_ONCHAIN_ZAP_SATS },
zaps.defaultZapType.value,
),
languages =
@@ -104,16 +106,11 @@ class AccountSyncedSettings(
reactions.reactionRowItems.tryEmit(newReactionRowItems)
}
val newZapChoices = syncedSettingsInternal.zaps.zapAmountChoices.toImmutableList()
val newZapChoices = mergeZapAmounts(syncedSettingsInternal.zaps).toImmutableList()
if (!equalImmutableLists(zaps.zapAmountChoices.value, newZapChoices)) {
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)
}
@@ -182,10 +179,18 @@ class AccountVideoPlayerPreferences(
@Stable
class AccountZapPreferences(
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
var onchainZapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType>,
)
/**
* Union the (historically separate) zap and on-chain preset lists into the
* single sorted set the app now uses everywhere. Preserves amounts customized
* on either side and amounts synced from an older client that still writes the
* legacy `onchainZapAmountChoices`. Idempotent: [AccountSyncedSettings.toInternal]
* re-derives the on-chain field as a subset of this list.
*/
internal fun mergeZapAmounts(zaps: AccountZapPreferencesInternal): List<Long> = (zaps.zapAmountChoices + zaps.onchainZapAmountChoices).distinct().sorted()
@Stable
class AccountLanguagePreferences(
var dontTranslateFrom: MutableStateFlow<Set<String>>,
@@ -41,6 +41,16 @@ val DefaultZapAmounts = listOf(21L, 50L, 100L)
val DefaultOnchainZapAmounts = listOf(5_000L)
val DefaultReportWarningThreshold = 5
/**
* Product floor for the on-chain rail stricter than the protocol-level
* dust threshold (OnchainZapBuilder.DUST_THRESHOLD_SATS). The unified zap
* picker offers the on-chain logo only for amounts at or above this, and the
* on-chain send dialog draws its presets from the single zap-amount list
* filtered by it. Single source of truth for everywhere that gates on-chain
* by amount.
*/
const val MIN_ONCHAIN_ZAP_SATS = 1_000L
@Serializable
enum class ReactionRowAction {
Reply,
@@ -154,6 +164,11 @@ class AccountReactionPreferencesInternal(
@Serializable
class AccountZapPreferencesInternal(
var zapAmountChoices: List<Long> = DefaultZapAmounts,
// Legacy field: the on-chain rail no longer has its own editable preset
// list — amounts live in [zapAmountChoices] and on-chain just filters by
// [MIN_ONCHAIN_ZAP_SATS]. Kept (de)serialized so older clients still sync
// and so the on-chain-eligible subset round-trips back for them; on load it
// is unioned into [zapAmountChoices]. See AccountSyncedSettings.
var onchainZapAmountChoices: List<Long> = DefaultOnchainZapAmounts,
val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
)
@@ -99,8 +99,8 @@ object RailCapabilityResolver {
* derives the Taproot address from the recipient pubkey, so any nostr
* pubkey is payable; lnAddress-only recipients are not (they have no
* pubkey to tweak). The sender's onchain wallet availability is a
* *sender* concern handled elsewhere by the popup gating the chip
* on `onchainZapAmountChoices` and the dialog on `LocalCache.onchainBackend`.
* *sender* concern handled elsewhere by the chip gating on-chain by
* `MIN_ONCHAIN_ZAP_SATS` and the dialog on `LocalCache.onchainBackend`.
*/
fun peek(
baseNote: Note,
@@ -44,7 +44,6 @@ 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
@@ -110,10 +109,6 @@ fun ReusableZapButton(
var onchainZapAmount by remember { mutableStateOf<Long?>(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) {
baseNote.author?.let { author ->
@@ -201,7 +196,6 @@ fun ReusableZapButton(
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
onchainZapAmountChoices = onchainZapAmountChoices,
onOnchainAmount = { amount ->
wantsToZap = null
onchainZapAmount = amount
@@ -110,6 +110,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ReactionRowAction
import com.vitorpamplona.amethyst.model.ReactionRowItem
@@ -1888,9 +1889,6 @@ fun ZapAmountChoicePopup(
val zapAmountChoices by
accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
.collectAsStateWithLifecycle()
val onchainZapAmountChoices by
accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices
.collectAsStateWithLifecycle()
ZapAmountChoicePopup(
baseNote = baseNote,
@@ -1903,7 +1901,6 @@ fun ZapAmountChoicePopup(
onError = onError,
onProgress = onProgress,
onPayViaIntent = onPayViaIntent,
onchainZapAmountChoices = onchainZapAmountChoices,
onOnchainAmount = onOnchainAmount ?: {},
onchainSupported = onOnchainAmount != null,
)
@@ -1921,15 +1918,14 @@ fun ZapAmountChoicePopup(
onError: (title: String, text: String, user: User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
onchainZapAmountChoices: ImmutableList<Long> = persistentListOf(),
onOnchainAmount: (Long?) -> Unit = {},
onchainSupported: Boolean = true,
) {
// One chip per amount; the chip itself shows which rails can pay it. Merge
// the (historically separate) Lightning and on-chain amount presets into a
// single sorted set of choices. Rail availability per amount is decided in
// [UnifiedZapAmountChip] from [railCapability], so the lists no longer need
// to be pre-emptied per rail.
// One chip per amount; the chip itself shows which rails can pay it.
// [zapAmountChoices] is already the single merged+sorted preset list (the
// on-chain amounts were folded in at the settings layer), so rail
// availability per amount is decided in [UnifiedZapAmountChip] from
// [railCapability] rather than by keeping separate per-rail lists.
//
// [railCapability] gates which rails the recipient can receive on (plus the
// sender's cashu balance, which is free to read). It's recomputed only when
@@ -1942,8 +1938,8 @@ fun ZapAmountChoicePopup(
if (onchainSupported) rc else rc.copy(hasOnchain = false)
}
val amountChoices =
remember(zapAmountChoices, onchainZapAmountChoices) {
(zapAmountChoices + onchainZapAmountChoices).distinct().sorted().toImmutableList()
remember(zapAmountChoices) {
zapAmountChoices.distinct().sorted().toImmutableList()
}
val visibilityState = rememberVisibilityState(onDismiss)
ZapAmountChoicePopup(
@@ -2087,14 +2083,6 @@ fun ZapAmountChoicePopupContent(
}
}
/**
* Floor for the on-chain rail. Mirrors `MIN_ONCHAIN_ZAP_SATS` in
* `OnchainZapSendDialog` (stricter than the protocol dust threshold); kept in
* sync by hand because that one is private to the dialog. Below this the
* on-chain logo is not offered for an amount.
*/
private const val MIN_ONCHAIN_ZAP_SATS = 1_000L
/**
* One pill per amount, showing a tappable logo for every rail that can pay it:
* - **Cashu** only when a single shared mint is already funded for the amount
@@ -339,96 +339,7 @@ fun UpdateZapAmountContent(
)
}
// ── Section 3: 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 = "5000, 25000, 100000",
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 4: Nostr Wallet Connect ───────────────────────────────────
// ── Section 3: Nostr Wallet Connect ───────────────────────────────────
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),
@@ -41,8 +41,6 @@ class UpdateZapAmountViewModel : ViewModel() {
var nextAmount by mutableStateOf(TextFieldValue(""))
var amountSet by mutableStateOf(listOf<Long>())
var nextOnchainAmount by mutableStateOf(TextFieldValue(""))
var onchainAmountSet by mutableStateOf(listOf<Long>())
var walletConnectRelay by mutableStateOf(TextFieldValue(""))
var walletConnectPubkey by mutableStateOf(TextFieldValue(""))
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
@@ -61,7 +59,6 @@ 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()
@@ -86,19 +83,6 @@ 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()
@@ -132,15 +116,13 @@ class UpdateZapAmountViewModel : ViewModel() {
null
}
accountViewModel.account.updateZapAmounts(amountSet, onchainAmountSet, selectedZapType, nip47Update)
accountViewModel.account.updateZapAmounts(amountSet, selectedZapType, nip47Update)
nextAmount = TextFieldValue("")
nextOnchainAmount = TextFieldValue("")
}
fun cancel() {
nextAmount = TextFieldValue("")
nextOnchainAmount = TextFieldValue("")
}
fun hasChanged(): Boolean {
@@ -148,7 +130,6 @@ 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 ?: "")
@@ -1297,10 +1297,9 @@ class AccountViewModel(
fun updateZapAmounts(
amountSet: List<Long>,
onchainAmountSet: List<Long>,
selectedZapType: LnZapEvent.ZapType,
nip47Update: Nip47WalletConnect.Nip47URINorm?,
) = launchSigner { account.updateZapAmounts(amountSet, onchainAmountSet, selectedZapType, nip47Update) }
) = launchSigner { account.updateZapAmounts(amountSet, selectedZapType, nip47Update) }
fun toggleDontTranslateFrom(languageCode: String) = launchSigner { account.toggleDontTranslateFrom(languageCode) }
@@ -74,6 +74,7 @@ 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.MIN_ONCHAIN_ZAP_SATS
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
@@ -99,13 +100,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.NumberFormat
// UX floor for onchain zaps. Below this any on-chain transaction is dominated
// by miner fees — the recipient nets close to nothing even at low fee rates,
// so quietly funneling the user to a Lightning zap is the friendlier outcome.
// This is stricter than the protocol-level [OnchainZapBuilder.DUST_THRESHOLD_SATS]
// (330 sats), which only guards against creating outputs the network rejects.
private const val MIN_ONCHAIN_ZAP_SATS = 1_000L
private enum class FeeTier(
val label: String,
val etaLabel: String,
@@ -213,8 +207,11 @@ fun OnchainZapSendDialog(
}
}
val presetAmounts by accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices
// On-chain shares the single zap-amount list now; show only the entries
// that clear the on-chain minimum as quick presets.
val zapAmountChoices by accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
.collectAsStateWithLifecycle()
val presetAmounts = remember(zapAmountChoices) { zapAmountChoices.filter { it >= MIN_ONCHAIN_ZAP_SATS } }
// 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
+1 -4
View File
@@ -869,10 +869,7 @@
<string name="wallet_connect_status_not_connected">Not connected</string>
<string name="wallet_connect_manual_config">Advanced: enter connection details manually</string>
<string name="quick_zap_amounts">Quick Zap Amounts</string>
<string name="quick_zap_amounts_explainer">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.</string>
<string name="quick_zap_amounts_onchain">Quick On-chain Zap Amounts</string>
<string name="quick_zap_amounts_onchain_explainer">On-chain zaps pay miner fees, so amounts are usually larger than Lightning. Tap an amount to remove it.</string>
<string name="new_amount_in_sats_onchain">New on-chain amount in sats</string>
<string name="quick_zap_amounts_explainer">Shown when pressing the zap button. Each amount can be paid by any rail the recipient supports — Lightning, Cashu, or on-chain (on-chain only for larger amounts). Tap an amount to remove it. If you leave it empty, it will open the dialog to insert an amount every time.</string>
<string name="send_onchain_instead">Send on-chain instead</string>
<string name="zap_privacy_section">Zap Privacy</string>
<string name="zap_type_section_explainer">Controls how your identity is shown when you send a zap.</string>
@@ -0,0 +1,71 @@
/*
* 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.model
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The on-chain rail's separate amount-preset list was folded into the single
* [AccountZapPreferencesInternal.zapAmountChoices]. These lock in that the
* migration union preserves amounts from both the legacy lists and round-trips
* idempotently (write the on-chain subset back, union it again same set).
*/
class ZapAmountMergeTest {
private fun zaps(
zap: List<Long>,
onchain: List<Long>,
) = AccountZapPreferencesInternal(zapAmountChoices = zap, onchainZapAmountChoices = onchain)
@Test
fun unionsDefaultsSortedAndDeduped() {
assertEquals(
listOf(21L, 50L, 100L, 5_000L),
mergeZapAmounts(zaps(DefaultZapAmounts, DefaultOnchainZapAmounts)),
)
}
@Test
fun preservesCustomAmountsFromBothLists() {
// Simulates a sync from an older client that still split the two lists.
assertEquals(
listOf(10L, 21L, 100L, 1_000L, 7_777L),
mergeZapAmounts(zaps(zap = listOf(21L, 100L, 10L), onchain = listOf(7_777L, 1_000L))),
)
}
@Test
fun dedupesAcrossLists() {
assertEquals(
listOf(100L, 5_000L),
mergeZapAmounts(zaps(zap = listOf(100L, 5_000L), onchain = listOf(5_000L))),
)
}
@Test
fun roundTripIsIdempotent() {
val merged = mergeZapAmounts(zaps(DefaultZapAmounts, DefaultOnchainZapAmounts))
// toInternal() writes the full list as zap and the on-chain-eligible
// subset as the legacy field; merging those again must not drift.
val onchainSubset = merged.filter { it >= MIN_ONCHAIN_ZAP_SATS }
assertEquals(merged, mergeZapAmounts(zaps(zap = merged, onchain = onchainSubset)))
}
}