fix(bolt12): audit fixes — offer binding, verified-only counting, lower-amount dedup, codec hardening

From an adversarial audit of the BOLT12-zap feature.

Security / correctness:
- Offer↔invoice binding: `cryptoVerified=true` was asserted even when the offer had
  no `offer_issuer_id` or used blinded paths — cases where the invoice's node key is
  payer-chosen and can't be tied to the offer. An attacker could self-sign a "verified"
  proof having paid nothing. Now cryptoVerified requires the invoice to be provably the
  offer's (issuer_id present, no paths, invoice_node_id == issuer); unbindable proofs are
  accepted but flagged unverified, not verified. Definite contradictions still hard-reject.
- Counting: `updateZapTotal` now counts ONLY crypto-verified BOLT12 zaps. An unverified
  (compressed / unbindable) proof carries a self-chosen preimage+amount with no settled-
  payment guarantee, so counting it let anyone inflate a note's total for free. Unverified
  entries stay stored + shown (dimmed), never summed.
- Dedup: `innerAddBolt12Zap` now honors the NIP's "count the LOWER amount for the same
  payment hash" rule (was order-dependent last-writer-wins, inflatable by re-publishing a
  bigger amount tag). Keeps the stronger verification flag.
- Precision: divide millisats in BigDecimal, so fractional sats survive and match the
  millisat-native lightning column (was integer `/1000`, flooring sub-sat zaps to 0).

Codec hardening (quartz):
- TLV length now range-checked (was a signed compare that let a high-bit BigSize length
  slip through and get truncated by toInt()).
- BigSize enforces minimal encoding (also rejects >=2^63 values that read back negative).
- bech32 alphabet membership is O(1) via a lookup table (was O(32n) indexOf per char).

UI:
- ReusableZapButton's "you zapped" gate now includes bolt12Zaps (and nutzaps/onchain),
  so a BOLT12-only zap correctly shows the zapped state.
- The reactions gallery renders the blank/unknown author for anonymous zaps, matching the
  standalone card (was showing the throwaway ephemeral key's avatar).

Tests: validator issuer-less-offer downgrade; TLV non-minimal-BigSize + oversized-length
rejection; model lower-amount dedup (both orderings), verified-only counting, and
fractional-sat survival. NoteBolt12ZapTest 6→8, Bolt12ZapValidatorTest 11→12, TlvTest 6→8.

The audit also surfaced a NIP-level gap that is NOT fixable in code and is captured in the
plan doc: there is no offer↔recipient-identity binding, so even a crypto-verified proof
only proves payment to the *embedded* offer, not to the p-tagged recipient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
This commit is contained in:
Claude
2026-07-23 21:17:01 +00:00
parent 3e19b76343
commit 0b6a70ad26
10 changed files with 162 additions and 37 deletions
@@ -739,14 +739,20 @@ open class Note(
): Boolean =
syncLock.withLock {
val existing = bolt12Zaps[paymentHashHex]
if (existing != null) {
// Same settled payment (dedup by invoice_payment_hash) — a relay echo.
if (entry == existing) return@withLock false
// Prefer a fully crypto-verified entry; never let an unverified
// (compressed-proof) republish overwrite a verified one.
if (!entry.cryptoVerified && existing.cryptoVerified) return@withLock false
}
bolt12Zaps = bolt12Zaps + Pair(paymentHashHex, entry)
val merged =
if (existing == null) {
entry
} else {
// Same settled payment (dedup by invoice_payment_hash). NIP-XX: count
// only one, and if amounts differ, keep the LOWER — so a re-publish
// with a bigger amount tag can't inflate the total. Keep the stronger
// verification flag, and the source of whichever entry we keep the
// amount from.
val keepEntry = if (entry.amountMillisats < existing.amountMillisats) entry else existing
keepEntry.copy(cryptoVerified = entry.cryptoVerified || existing.cryptoVerified)
}
if (merged == existing) return@withLock false
bolt12Zaps = bolt12Zaps + Pair(paymentHashHex, merged)
return@withLock true
}
@@ -1102,11 +1108,16 @@ open class Note(
sumOfAmounts += BigDecimal(entry.claimedSats)
}
// NIP-XX BOLT12 zaps — validated synchronously at consume time (the `lnp`
// payer proof is a self-contained settlement proof), so every stored entry
// counts, converting its millisat amount to sats like the lightning path.
// NIP-XX BOLT12 zaps — count only the crypto-verified ones. An unverified
// (compressed, or offer-unbindable) proof carries a self-chosen preimage,
// amount, and payment hash with no settled-payment guarantee, so counting it
// would let anyone inflate a note's total for free. Unverified entries stay
// stored and shown (dimmed) but never sum. Divide in BigDecimal so fractional
// sats survive and match the millisat-native lightning column above.
bolt12Zaps.values.forEach { entry ->
sumOfAmounts += BigDecimal(entry.amountMillisats / 1000)
if (entry.cryptoVerified) {
sumOfAmounts += BigDecimal(entry.amountMillisats).divide(BigDecimal(1000))
}
}
zapsAmount = sumOfAmounts