mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
@@ -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)
|
||||
|
||||
+22
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user