fix: consume Marmot inner reactions/deletions as verified

MIP-03 inner events inside a kind:445 GroupEvent are unsigned (sig is
empty or placeholder), so LocalCache's `justVerify()` fails and
`consume(ReactionEvent)` skips the branch that attaches the reaction to
its target note via `addReaction()`. The same applies to kind:5
deletions. Side-channel inner events from other Marmot clients
(WhiteNoise in particular) were therefore silently dropped by
LocalCache, so an inbound emoji reaction never appeared on the target
chat bubble.

NIP-17 has the same "unsigned inner rumor" shape and already hands the
inner event to `justConsume(..., wasVerified = true)`. Align Marmot with
that contract at the two inbound call sites:
- `GroupEventHandler.add` (fresh kind:445 from relay)
- `Account` restore-from-disk loop (persisted plaintext at app start)

Authenticity is already guaranteed by the MLS layer:
`MarmotInboundProcessor.processPrivateMessage` rejects any inner event
whose `pubkey` doesn't match the MLS sender credential identity before
ever emitting an `ApplicationMessage`.

https://claude.ai/code/session_01KhVtLJAEkNCwpRWUUTqDFM
This commit is contained in:
Claude
2026-04-23 00:34:51 +00:00
parent 95f189338a
commit 5106e572a5
2 changed files with 15 additions and 2 deletions
@@ -3018,7 +3018,12 @@ class Account(
val innerEvent =
com.vitorpamplona.quartz.nip01Core.core.Event
.fromJson(json)
val isNew = cache.justConsume(innerEvent, null, false)
// wasVerified=true: these events were already verified
// via MLS credential identity when first decrypted (see
// GroupEventHandler). Persisted inner events are
// unsigned per MIP-03, so justVerify would fail and
// reactions/deletions would silently drop here.
val isNew = cache.justConsume(innerEvent, null, true)
val innerNote = cache.getOrCreateNote(innerEvent.id)
if (isNew) {
innerNote.event = innerEvent
@@ -592,7 +592,15 @@ class GroupEventHandler(
"GroupEventHandler.add: ApplicationMessage decrypted innerKind=${innerEvent.kind} " +
"innerId=${innerEvent.id.take(8)}… author=${innerEvent.pubKey.take(8)}"
}
if (cache.justConsume(innerEvent, null, false)) {
// wasVerified=true: MIP-03 inner events are unsigned (sig is empty
// or placeholder), so justVerify() would fail. Authenticity is
// already guaranteed by the MLS layer — MarmotInboundProcessor
// enforces innerEvent.pubKey == MLS sender credential identity
// before returning ApplicationMessage. Without this, side-channel
// inner events from other Marmot clients (kind:7 reactions,
// kind:5 deletions) are silently dropped by LocalCache, so a
// WhiteNoise reaction never attaches to its target chat bubble.
if (cache.justConsume(innerEvent, null, true)) {
val innerNote = cache.getOrCreateNote(innerEvent.id)
innerNote.event = innerEvent