fix: on-chain zap splits — drop sender from splits, merge duplicates, gate Send on dust

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<HexKey>.
- 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
This commit is contained in:
Claude
2026-05-20 20:14:44 +00:00
parent 3ed2245d8c
commit 45aa6044b7
6 changed files with 303 additions and 44 deletions
@@ -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<HexKey> = emptyList(),
) : OnchainZapSendResult
}
@@ -359,6 +365,7 @@ object OnchainZapSender {
"but the next receipt could not be published",
cause = e,
broadcastTxid = txid,
publishedReceiptEventIds = publishedIds.toList(),
)
}
}
@@ -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<Pair<HexKey, Double>>,
senderPubKey: HexKey,
): List<Pair<HexKey, Double>> {
val merged = linkedMapOf<HexKey, Double>()
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<Pair<HexKey, Double>>,
@@ -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<OnchainZapEvent>()
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<OnchainZapEvent>(template)
publishedReceipts += event
event
}
assertIs<OnchainZapSendResult.Success>(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<OnchainZapSendResult.Failure>(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)
}
}
@@ -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 })
}
}