fix: audit follow-ups (fee retry, perf, self-pay gate)
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<Event>() is now wrapped in remember(baseNote)
in all three dialog launchers so it's not allocated on every
parent recomposition.
This commit is contained in:
+29
-7
@@ -79,6 +79,27 @@ object OnchainZapSplitter {
|
||||
totalSats: Long,
|
||||
splits: List<Pair<HexKey, Double>>,
|
||||
dustThresholdSats: Long,
|
||||
): List<OnchainZapShare> {
|
||||
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<Pair<HexKey, Double>>,
|
||||
): List<OnchainZapShare> = computeShares(totalSats, splits)
|
||||
|
||||
private fun computeShares(
|
||||
totalSats: Long,
|
||||
splits: List<Pair<HexKey, Double>>,
|
||||
): List<OnchainZapShare> {
|
||||
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<Int> { 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user