From 917e38c19f84fd3b51b3811aa9ea09f34fa8584f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 23:56:44 +0000 Subject: [PATCH] fix: make rumor wrap rebroadcast reachable in both audit edge cases - Bare-seal hosts (kind 13) carry no p tag, so the re-download filter's p constraint silently matched nothing for them; the p filter is now wrap-only (the ids filter is sufficient for seals) - A just-sent private note has no relays until its self-wrap echoes back from the DM relays; the fetch now falls back to the account's own DM inbox relay set when note.relays is empty Also pins the citation guarantee with RumorHostCitationTest: a rumor note's nevent must encode the delivering wrap's id, never the private rumor id, and public notes keep citing their own id. https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo --- .../vitorpamplona/amethyst/model/Account.kt | 32 +++--- .../commons/model/RumorHostCitationTest.kt | 97 +++++++++++++++++++ 2 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/RumorHostCitationTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 321c334aac..d7904795d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1333,20 +1333,28 @@ class Account( note.event?.let { noteEvent -> val host = note.rumorHost if (host != null) { - // Rumors are rebroadcast as their delivering wrap: - // download the wrap and send it. + // Rumors are rebroadcast as their delivering envelope: the + // cached copy is content-stripped, so download it and send it. + // A just-sent note has no relays until its self-wrap echoes + // back — fall back to our own DM inbox relays. Bare seals + // (kind 13) carry no p tag, so that filter is wrap-only. + val relays = note.relays.ifEmpty { dmRelays.flow.value.toList() } + val filter = + if (host.kind == SealedRumorEvent.KIND) { + Filter( + kinds = listOf(host.kind), + ids = listOf(host.id), + ) + } else { + Filter( + kinds = listOf(host.kind), + tags = mapOf("p" to listOf(pubKey)), + ids = listOf(host.id), + ) + } client .fetchFirst( - filters = - note.relays.associateWith { _ -> - listOf( - Filter( - kinds = listOf(host.kind), - tags = mapOf("p" to listOf(pubKey)), - ids = listOf(host.id), - ), - ) - }, + filters = relays.associateWith { _ -> listOf(filter) }, )?.let { downloadedEvent -> val toRelays = computeRelayListToBroadcast(downloadedEvent) client.publish(downloadedEvent, toRelays) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/RumorHostCitationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/RumorHostCitationTest.kt new file mode 100644 index 0000000000..55f863fc5d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/RumorHostCitationTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model + +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Pins the citation guarantee for private notes: an nevent of a rumor must + * encode the delivering envelope's id — never the rumor's own id, which is + * the private event's identity and resolves to nothing on public relays. + */ +class RumorHostCitationTest { + private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray())) + + private val bobPriv = "0000000000000000000000000000000000000000000000000000000000000008" + private val bobSigner = NostrSignerInternal(KeyPair(bobPriv.hexToByteArray())) + + @Test + fun rumorNote_toNEvent_citesTheDeliveringWrap() = + runTest { + // Alice sends Bob a private note; Bob unwraps his copy. + val template = + TextNoteEvent.build("psst") { + pTags(listOf(PTag(bobSigner.pubKey, null))) + } + val result = NIP17Factory().createNoteNIP17(template, aliceSigner) + val bobWrap = result.wraps.first { it.recipientPubKey() == bobSigner.pubKey } + val rumor = bobWrap.unwrapAndUnsealOrNull(bobSigner) + assertNotNull(rumor) + assertTrue(rumor.sig.isEmpty()) + + // Bob's cache materializes the rumor note and records the wrap. + val note = Note(rumor.id) + note.event = rumor + note.recordRumorHost(bobWrap) + + val nevent = note.toNEvent() + assertEquals( + NEvent.create(bobWrap.id, bobWrap.pubKey, bobWrap.kind, null), + nevent, + "rumor citations must encode the wrap, not the rumor", + ) + assertFalse( + nevent == NEvent.create(rumor.id, rumor.pubKey, rumor.kind, null), + "the private rumor id must never be encoded", + ) + } + + @Test + fun publicNote_toNEvent_citesItsOwnId() = + runTest { + val event = aliceSigner.sign(TextNoteEvent.build("hello world")) + + val note = Note(event.id) + note.event = event + + // toNEvent reads the author from the Note (unset here), so the + // expected nevent carries a null author too. + assertEquals( + NEvent.create(event.id, null, event.kind, null), + note.toNEvent(), + ) + } +}