fix(onchain): highlight bolt for own onchain zaps + own pending in counter

Two parallel gaps to the cashu work, surfaced once the cashu side
was wired correctly:

1. The orange bolt highlight on the reaction row never lit up for
   onchain zaps. Note.isZappedBy checked LN zaps, NWC payments,
   and (since Phase 1) nutzaps — but never onchainZaps. And the
   fast-path gate in ObserveZapIconState shared the same blind
   spot. Add isOnchainZappedBy parallel to isNutzappedBy (same
   shape: any onchainZaps entry whose source.author matches the
   user and whose source event is newer than afterTimeInSeconds),
   and extend the gate with onchainZaps?.isNotEmpty().

2. The reaction-row counter included CONFIRMED onchain amounts
   via updateZapTotal (verifiedSats only, per NIP-BC) but not
   the signed-in user's OWN pending/unverified outgoing zaps.
   That created a UX mismatch: the gallery shows the user's own
   UNVERIFIED entry with its claimed sat amount immediately
   (the user knows what they sent), but the counter stays at 0
   until the chain catches up. Add
   Note.extraOwnPendingOnchainSats(loggedInPubKey) that sums
   claimedSats from non-CONFIRMED onchainZaps whose source.author
   matches the logged-in pubkey, and add it on top of zapsAmount
   in both AccountViewModel.calculateZapAmount paths and
   ObserveZapAmountText's no-zapPayments fast path. Other senders'
   non-confirmed entries still contribute 0, preserving the
   anti-spoof posture for incoming zaps.
This commit is contained in:
Claude
2026-05-28 23:06:10 +00:00
parent 6312c24f2f
commit f8ff9049d9
3 changed files with 62 additions and 12 deletions
@@ -1458,7 +1458,8 @@ fun ObserveZapIconState(
val hasZapData =
zapsState?.note?.zapPayments?.isNotEmpty() == true ||
zapsState?.note?.zaps?.isNotEmpty() == true ||
zapsState?.note?.nutzaps?.isNotEmpty() == true
zapsState?.note?.nutzaps?.isNotEmpty() == true ||
zapsState?.note?.onchainZaps?.isNotEmpty() == true
val wasZapped =
if (hasZapData) {
accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, afterTimeInSeconds)
@@ -1538,7 +1539,14 @@ fun ObserveZapAmountText(
inner(zapAmountTxt)
} else {
inner(showAmount(zapsState?.note?.zapsAmount))
// Include the signed-in user's own pending onchain zaps so
// the counter reflects the optimistic value the gallery shows.
val ownPubKey = accountViewModel.account.userProfile().pubkeyHex
val note = zapsState?.note
val total =
(note?.zapsAmount ?: java.math.BigDecimal(0)) +
java.math.BigDecimal(note?.extraOwnPendingOnchainSats(ownPubKey) ?: 0L)
inner(showAmount(total))
}
}
@@ -623,15 +623,21 @@ class AccountViewModel(
account.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds)
}
suspend fun calculateZapAmount(zappedNote: Note): String =
if (zappedNote.zapPayments.isNotEmpty()) {
suspend fun calculateZapAmount(zappedNote: Note): String {
// The signed-in user's own outgoing onchain zaps that aren't
// yet CONFIRMED still need to show in the counter — the user
// knows what they sent, so make the counter reflect reality
// immediately instead of waiting for chain confirmation.
val ownPendingOnchain = zappedNote.extraOwnPendingOnchainSats(account.userProfile().pubkeyHex)
return if (zappedNote.zapPayments.isNotEmpty()) {
withContext(Dispatchers.IO) {
val it = account.calculateZappedAmount(zappedNote)
showAmount(it)
val nwc = account.calculateZappedAmount(zappedNote)
showAmount(nwc + java.math.BigDecimal(ownPendingOnchain))
}
} else {
showAmount(zappedNote.zapsAmount)
showAmount(zappedNote.zapsAmount + java.math.BigDecimal(ownPendingOnchain))
}
}
suspend fun calculateZapraiser(zappedNote: Note): ZapraiserStatus {
val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0
@@ -769,12 +769,14 @@ open class Note(
afterTimeInSeconds: Long,
account: IAccount,
): Boolean {
// NIP-61 nutzaps: the sender is the source event's pubkey, so
// the check is direct — no private-zap decryption needed (cashu
// doesn't have a private-recipient variant the way NIP-57 does).
// Run this first; it's an in-memory scan and a hit short-circuits
// the more expensive lightning path.
// NIP-61 nutzaps and NIP-BC onchain zaps: the sender is the
// source event's pubkey, so the check is direct — no
// private-zap decryption needed. Run these first; they're
// in-memory scans and a hit short-circuits the more expensive
// lightning path (which may have to decrypt NIP-44-private
// zap requests).
if (isNutzappedBy(user, afterTimeInSeconds)) return true
if (isOnchainZappedBy(user, afterTimeInSeconds)) return true
val first = isZappedByCalculation(null, user, afterTimeInSeconds, account, zaps)
if (first) return true
@@ -793,6 +795,40 @@ open class Note(
entry.source.author == user && sourceEvent.createdAt > afterTimeInSeconds
}
private fun isOnchainZappedBy(
user: User,
afterTimeInSeconds: Long,
): Boolean =
onchainZaps.values.any { entry ->
val sourceEvent = entry.source.event ?: return@any false
entry.source.author == user && sourceEvent.createdAt > afterTimeInSeconds
}
/**
* Extra sats to add on top of [zapsAmount] for the reaction-row
* counter when the signed-in user has outgoing onchain zaps on
* this note that aren't yet CONFIRMED. [updateZapTotal] only
* counts CONFIRMED onchain entries (verifiedSats) because incoming
* sender-claimed amounts are spoofable. The signed-in user's OWN
* outgoing zap is trusted at face value though — they know what
* they sent — so the counter should reflect it immediately, the
* same way the gallery shows their own UNVERIFIED entry with the
* claimed amount. Other senders' non-confirmed entries still
* contribute 0 here.
*/
fun extraOwnPendingOnchainSats(loggedInPubKey: HexKey?): Long {
if (loggedInPubKey == null) return 0L
var sum = 0L
onchainZaps.values.forEach { entry ->
if (entry.status != OnchainZapStatus.CONFIRMED &&
entry.source.author?.pubkeyHex == loggedInPubKey
) {
sum += entry.claimedSats
}
}
return sum
}
suspend fun isZappedBy(
option: Int?,
user: User,