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:
+7
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -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>>,
|
||||
|
||||
+82
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user