fix(bolt12): don't let an unverified proof grief a verified zap total

The payment-hash dedup kept the LOWER amount and OR'd cryptoVerified across
entries. Because a payer proof publishes its proof_preimage, once a BOLT12 zap
is public anyone can replay its payment hash in a compressed (unverifiable)
proof with a 1-msat amount; the merge would keep that amount AND inherit the
verified flag, driving the counted total to ~zero and mislabeling a fabricated
amount as verified.

Per the NIP, dedup by invoice_payment_hash applies among *validated* proofs.
Only a crypto-verified proof has a signature-bound amount, so a verified entry
now always wins over an unverified duplicate; the lower-amount rule applies only
between entries of the same verification status. Adds a two-order regression
test for the replay-griefing case.

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-24 20:16:38 +00:00
parent 70d38b205e
commit 6d111626ee
2 changed files with 38 additions and 7 deletions
@@ -742,14 +742,23 @@ open class Note(
val merged =
if (existing == null) {
entry
} else if (entry.cryptoVerified != existing.cryptoVerified) {
// Cross-verification dedup. Only a crypto-verified proof has an amount
// bound by the invoice signature; an unverified one (a compressed proof,
// or an offer we can't bind) carries a self-chosen amount and payment
// hash. Because a payer proof publishes its `proof_preimage`, once any
// BOLT12 zap is public anyone can replay that preimage in a fresh,
// unverifiable proof with a 1-msat amount and the same payment hash — so
// if we let the lower amount win here and inherited the verified flag, a
// griefer could drive the counted total to ~zero and mislabel a fake
// amount as verified. Keep the verified entry outright, whichever amount
// it carries; never let an unverified duplicate override it.
if (entry.cryptoVerified) entry else existing
} 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)
// Same verification status: dedup by the settled payment and, if amounts
// differ, keep the LOWER — so a re-publish with a bigger amount tag can't
// inflate the total (NIP-XX). Both share the same cryptoVerified flag.
if (entry.amountMillisats < existing.amountMillisats) entry else existing
}
if (merged == existing) return@withLock false
bolt12Zaps = bolt12Zaps + Pair(paymentHashHex, merged)
@@ -80,6 +80,28 @@ class NoteBolt12ZapTest {
assertTrue(target.bolt12Zaps["h"]!!.cryptoVerified, "a verified entry must not be overwritten by an unverified one")
}
@Test
fun aLowerUnverifiedRepublishCannotGriefAVerifiedTotal() {
// A payer proof publishes its proof_preimage, so once a BOLT12 zap is public
// anyone can replay the same payment hash in an UNVERIFIABLE (e.g. compressed)
// proof carrying a self-chosen 1-msat amount. The lower-amount dedup MUST apply
// only among validated proofs — an unverified duplicate can neither lower the
// counted amount nor inherit the verified flag. Both arrival orders.
val verifiedFirst = note("a".repeat(64))
verifiedFirst.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true)
verifiedFirst.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 1L, cryptoVerified = false)
assertEquals(1, verifiedFirst.bolt12Zaps.size)
assertTrue(verifiedFirst.bolt12Zaps["h"]!!.cryptoVerified)
assertEquals(5_000L, verifiedFirst.zapsAmount.toLong(), "an unverified 1-msat replay must not grief the total down")
val unverifiedFirst = note("a".repeat(64))
unverifiedFirst.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 1L, cryptoVerified = false)
unverifiedFirst.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true)
assertEquals(1, unverifiedFirst.bolt12Zaps.size)
assertTrue(unverifiedFirst.bolt12Zaps["h"]!!.cryptoVerified)
assertEquals(5_000L, unverifiedFirst.zapsAmount.toLong(), "order must not matter")
}
@Test
fun removingBySourceDropsTheEntryAndUpdatesTheTotal() {
val target = note("a".repeat(64))