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:
Claude
2026-05-20 20:49:33 +00:00
parent 85dfe93ea7
commit f6db678249
6 changed files with 88 additions and 42 deletions
@@ -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)
}
}
}
@@ -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