diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 9704682c33..910adf0a82 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -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) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt index 1217bdf50b..72f17ca609 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt @@ -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))