diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt index 99602208e1..3b8e3ff2ea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt @@ -28,8 +28,18 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey * * Rules enforced here: * - **Genesis anchoring** — a chain starts at the lowest-version edition with no - * `ep` (prev hash). Without a genesis, the entity has no accepted head (we - * hold rather than trust an unrooted later edition). + * `ep` (prev hash). + * - **Refounding fallback (fresh joiner)** — when no genesis is present, anchor + * at the lowest-version edition available and accept it as the baseline. After + * a Refounding (CORD-06 §3) the compacted head still carries the `ep` it had + * before compaction, citing an edition in the *prior* epoch that a fresh joiner + * never fetches — so a dangling `prev` is the norm, not corruption, and CORD-04 + * §1 ("Folding across a Refounding") requires the joiner to take that head as + * its baseline. The signature + owner-rooted authority check (applied by + * [com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver] on top of this + * structural fold) is the whole test, so an unrooted forgery is still dropped + * there. Amethyst always re-folds the whole buffer from scratch, so it is + * structurally always a fresh joiner; it holds no prior chain to fail closed on. * - **Intact chain / no downgrades** — the head advances to `version + 1` only * when that edition's `ep` cites the current head's [ControlEdition.hash]. * Lower or non-chaining versions are ignored. @@ -60,11 +70,16 @@ object EditionFold { val byVersion = HashMap>() for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e) - // Genesis: lowest version with no prev hash. Prefer the tie-break winner. + // Anchor at the genesis (lowest version with no prev hash), preferring the + // tie-break winner. When no genesis is present — the compacted head of a + // Refounded community carries a prev citing the prior epoch — a fresh joiner + // anchors at the lowest-version edition it does hold and accepts it as the + // baseline (CORD-04 §1 / CORD-06 §3). `editions` is non-empty here. var head = editions .filter { it.prevHash == null } .minWithOrNull(compareBy({ it.version }, { it.rumorId })) + ?: editions.minWithOrNull(compareBy({ it.version }, { it.rumorId })) ?: return null // Walk the chain upward while the next version chains from the current head. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt index 85e1551f44..16ca3ddc93 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt @@ -148,9 +148,15 @@ class ControlEditionTest { assertEquals("aaa", EditionFold.foldEntity(listOf(b, a))?.rumorId) } + /** + * A lone edition with a dangling `prev` and no genesis is the compacted head of a Refounded + * community (CORD-06 §3): a fresh joiner never holds the prior epoch it chains onto, so the + * head is accepted as the baseline rather than dropped (CORD-04 §1). Dropping it was the bug + * that hid a refounded community's icon, name, and edited channels. See [EditionFoldTest]. + */ @Test - fun foldWithoutGenesisReturnsNull() { + fun foldWithoutGenesisAcceptsCompactedHead() { val v1 = edition(1, ByteArray(32) { 0x05 }, """{"name":"x"}""", "id1") - assertNull(EditionFold.foldEntity(listOf(v1))) + assertEquals("id1", EditionFold.foldEntity(listOf(v1))?.rumorId) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldTest.kt new file mode 100644 index 0000000000..60ba6d58f3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldTest.kt @@ -0,0 +1,105 @@ +/* + * 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.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class EditionFoldTest { + private val author = KeyPair().pubKey.toHexKey() + private val eid = ByteArray(32) { 0xAB.toByte() } + + private fun edition( + version: Long, + prevHash: ByteArray?, + content: String, + rumorId: String = "id-v$version", + ) = ControlEdition( + entityKind = ControlEntityKind.CHANNEL, + entityId = eid, + version = version, + prevHash = prevHash, + authorityCitation = null, + content = content, + author = author, + rumorId = rumorId, + createdAt = 1_700_000_000L + version, + ) + + /** A normal chain (v0 genesis → v1 → v2) folds to the highest intact version. */ + @Test + fun foldsIntactChainToHead() { + val v0 = edition(0, null, "genesis") + val v1 = edition(1, v0.hash, "one") + val v2 = edition(2, v1.hash, "two") + + val head = EditionFold.foldEntity(listOf(v2, v0, v1)) + assertEquals(2, head?.version) + assertEquals("two", head?.content) + } + + /** + * The Refounding case (CORD-06 §3): a fresh joiner holds only the compacted head, whose + * `prev` cites the prior epoch it never fetched. With no genesis present, the head is + * accepted as the baseline rather than dropped — the bug that hid a refounded community's + * icon, name, and edited channels. + */ + @Test + fun acceptsDanglingCompactedHeadWhenNoGenesis() { + val danglingHead = edition(5, ByteArray(32) { 0x99.toByte() }, "compacted-head") + + val head = EditionFold.foldEntity(listOf(danglingHead)) + assertEquals(5, head?.version) + assertEquals("compacted-head", head?.content) + } + + /** A dangling head plus post-refounding edits chains forward from the accepted baseline. */ + @Test + fun advancesFromDanglingHeadAsNewEditionsArrive() { + val danglingHead = edition(5, ByteArray(32) { 0x99.toByte() }, "compacted-head") + val v6 = edition(6, danglingHead.hash, "post-refound edit") + + val head = EditionFold.foldEntity(listOf(v6, danglingHead)) + assertEquals(6, head?.version) + assertEquals("post-refound edit", head?.content) + } + + /** A genuine mid-chain gap still fails closed at the intact prefix — no silent jump past the hole. */ + @Test + fun stopsAtGapWhenGenesisPresent() { + val v0 = edition(0, null, "genesis") + // v1 is missing; v2 cites a hash we don't hold, so it can't chain onto v0. + val v2 = edition(2, ByteArray(32) { 0x77.toByte() }, "orphan") + + val head = EditionFold.foldEntity(listOf(v2, v0)) + assertEquals(0, head?.version) + assertEquals("genesis", head?.content) + } + + /** No editions at all → no head. */ + @Test + fun emptyFoldsToNull() { + assertNull(EditionFold.foldEntity(emptyList())) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt index d5811c0501..fa919e9b14 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt @@ -22,6 +22,13 @@ package com.vitorpamplona.quartz.concord.cord06Rekey import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -126,6 +133,70 @@ class ConcordRefoundingTest { } } + @Test + fun freshJoinerSeesEntitiesEditedAfterGenesisThenRefounded() = + runTest { + // A real, long-lived community edits its metadata (adds an icon) and renames + // #general AFTER genesis, THEN gets refounded. Those edits produce version-1 + // editions whose `ep` chains onto the genesis edition. Compaction keeps only + // each entity's head — so the re-wrapped heads still carry a `prev` pointing at + // the (now absent) prior-epoch edition. A fresh joiner fetching only the + // compacted heads must still see them (CORD-04 §1 "Folding across a Refounding", + // CORD-06 §3): the signature + current-authority check is the whole test. + val community = ConcordCommunityFactory.create(owner, "NosFabrica", now) + val communityId = community.communityId + val control = community.controlPlane + + val genesisMeta = community.genesisEditions.first { it.entityKind == ControlEntityKind.METADATA } + val genesisChannel = community.genesisEditions.first { it.entityKind == ControlEntityKind.CHANNEL } + + val icon = ImagePointer(url = "https://media/icon.enc", key = "1a".repeat(32), nonce = "2b".repeat(16), hash = "3c".repeat(32)) + + // v1 metadata: add the icon, chained onto genesis. + val metaV1Json = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), MetadataEntity(name = "NosFabrica", icon = icon)) + val metaV1Rumor = ControlEditionBuilder.rumor(owner.pubKey, ControlEntityKind.METADATA, communityId, 1, genesisMeta.hash, metaV1Json, now + 1) + val metaV1Wrap = ConcordStreamEnvelope.wrap(metaV1Rumor, control, owner, encrypted = false, createdAt = now + 1) + + // v1 channel: rename #general, chained onto genesis. + val chanV1Json = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), ChannelEntity(name = "lobby", private = false)) + val chanV1Rumor = ControlEditionBuilder.rumor(owner.pubKey, ControlEntityKind.CHANNEL, community.generalChannelId, 1, genesisChannel.hash, chanV1Json, now + 1) + val chanV1Wrap = ConcordStreamEnvelope.wrap(chanV1Rumor, control, owner, encrypted = false, createdAt = now + 1) + + val priorWraps = community.genesisWraps + metaV1Wrap + chanV1Wrap + + val build = + ConcordRefounding.build( + rotatorSigner = owner, + communityId = communityId, + priorRoot = community.communityRoot, + newRoot = newRoot, + rootEpoch = community.rootEpoch, + priorControlWraps = priorWraps, + priorControlKey = control, + recipientsXOnly = listOf(alice.pubKey), + createdAt = now, + ) + + val newControl = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, build.newEpoch) + val editions = + build.controlWraps.mapNotNull { wrap -> + ConcordStreamEnvelope.openOrNull(wrap, newControl)?.let { ControlEdition.fromRumor(it.rumor) } + } + val folded = ConcordCommunityState.fold(editions, owner.pubKey) + + // A fresh joiner MUST see the compacted heads — name, icon, and the renamed channel. + assertEquals("NosFabrica", folded.metadata?.name, "fresh joiner lost the community name after refounding") + assertEquals(icon, folded.metadata?.icon, "fresh joiner lost the community icon after refounding") + assertEquals( + "lobby", + folded.channels.values + .firstOrNull() + ?.definition + ?.name, + "fresh joiner lost the (edited) channel after refounding", + ) + } + @Test fun wrongPriorRootFailsContinuity() = runTest {