fix(concord): read/write the kind-13302 list in Armada's wire format

Amethyst serialized the joined-community list (kind 13302) as a bare JSON
array of flat camelCase entries, so it could not parse the document Soapbox
Armada actually publishes — a community created in Armada never appeared in
Amethyst even after the 13302 reached a shared relay.

Rewrites the codec to Armada's communityList.ts shape:
{ entries: [ { community_id, seed: JoinMaterial, current: JoinMaterial,
added_at } ], tombstones: [ { community_id, removed_at } ] }, where
JoinMaterial is the snake_case per-snapshot key bundle (community_id, owner,
owner_salt, community_root, root_epoch, channels[], relays, name, held_roots?,
refounder?). Hydration prefers current over seed; liveness is derived from
tombstones (an entry is dropped only when removed strictly after it was added).
New create/join entries now stamp added_at (ms) so the liveness tiebreak works.

Adds interop tests that decode a real Armada document and exercise the
tombstone-after-add drop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-11 14:56:30 +00:00
parent 127d959c8d
commit 0015b39085
3 changed files with 194 additions and 8 deletions
@@ -1837,6 +1837,7 @@ class Account(
rootEpoch = community.rootEpoch,
relays = relayUrls,
name = name,
addedAt = TimeUtils.now() * 1000,
),
)
return community.communityIdHex
@@ -1901,6 +1902,7 @@ class Account(
rootEpoch = bundle.rootEpoch,
relays = bundle.relays,
name = bundle.name,
addedAt = TimeUtils.now() * 1000,
)
joinConcordCommunity(entry)
return bundle.communityId
@@ -23,8 +23,8 @@ package com.vitorpamplona.quartz.concord.cord02Community
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
/** A past root key for a specific epoch, kept so historical channel keys stay derivable. */
@Serializable
@@ -39,6 +39,7 @@ class PrivateChannelKey(
val channelId: String,
val key: String,
val epoch: Long,
val name: String = "",
)
/**
@@ -46,7 +47,8 @@ class PrivateChannelKey(
* needed to re-derive the community's planes on any device: identity ([id],
* [owner], [ownerSalt]), the current access [root] at [rootEpoch] plus past
* [heldRoots], any [privateChannels] keys, bootstrap [relays], and a cached
* display [name].
* display [name]. [addedAt] is the wire join timestamp (ms) that tiebreaks
* liveness against tombstones.
*/
@Serializable
class ConcordCommunityListEntry(
@@ -59,6 +61,7 @@ class ConcordCommunityListEntry(
val privateChannels: List<PrivateChannelKey> = emptyList(),
val relays: List<String> = emptyList(),
val name: String = "",
val addedAt: Long = 0,
)
/**
@@ -67,10 +70,97 @@ class ConcordCommunityListEntry(
* lets a client return to the groups the user signed up for. Replaceable and
* NIP-44-encrypted to the member's own key, so relays store only ciphertext.
*
* (Channels are not listed here: once the [root] is held, folding the Control
* Plane yields the community's channels.)
* The plaintext document is wire-compatible with Soapbox Armada's `communityList.ts`:
* `{ "entries": [ { "community_id", "seed": JoinMaterial, "current": JoinMaterial,
* "added_at" } ], "tombstones": [ { "community_id", "removed_at" } ] }`, where
* [JoinMaterialWire] is the snake_case per-snapshot key bundle. Liveness is derived —
* an entry is dropped only when a later tombstone removes it — and each entry keeps a
* [CommunityListEntryWire.seed] (backfill anchor) plus [CommunityListEntryWire.current]
* (latest) snapshot; we hydrate from `current`, falling back to `seed`.
*
* (Channels are not listed here beyond their private keys: once the [root] is held,
* folding the Control Plane yields the community's channels.)
*/
object ConcordCommunityList {
// ---- wire DTOs (snake_case, Armada communityList.ts) ----------------------
@Serializable
private class WireChannel(
val id: String,
val key: String,
val epoch: Long,
val name: String = "",
)
@Serializable
private class WireHeldRoot(
val epoch: Long,
val key: String,
)
@Serializable
private class JoinMaterialWire(
@SerialName("community_id") val communityId: String,
val owner: String,
@SerialName("owner_salt") val ownerSalt: String,
@SerialName("community_root") val communityRoot: String,
@SerialName("root_epoch") val rootEpoch: Long,
val channels: List<WireChannel> = emptyList(),
val relays: List<String> = emptyList(),
val name: String = "",
@SerialName("held_roots") val heldRoots: List<WireHeldRoot> = emptyList(),
val refounder: String? = null,
)
@Serializable
private class CommunityListEntryWire(
@SerialName("community_id") val communityId: String,
val seed: JoinMaterialWire? = null,
val current: JoinMaterialWire? = null,
@SerialName("added_at") val addedAt: Long = 0,
)
@Serializable
private class CommunityTombstoneWire(
@SerialName("community_id") val communityId: String,
@SerialName("removed_at") val removedAt: Long = 0,
)
@Serializable
private class CommunityListDoc(
val entries: List<CommunityListEntryWire> = emptyList(),
val tombstones: List<CommunityTombstoneWire> = emptyList(),
)
private fun ConcordCommunityListEntry.toJoinMaterial() =
JoinMaterialWire(
communityId = id,
owner = owner,
ownerSalt = ownerSalt,
communityRoot = root,
rootEpoch = rootEpoch,
channels = privateChannels.map { WireChannel(it.channelId, it.key, it.epoch, it.name) },
relays = relays,
name = name,
heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key) },
)
private fun JoinMaterialWire.toEntry(addedAt: Long) =
ConcordCommunityListEntry(
id = communityId,
owner = owner,
ownerSalt = ownerSalt,
root = communityRoot,
rootEpoch = rootEpoch,
heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key) },
privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name) },
relays = relays,
name = name,
addedAt = addedAt,
)
// ---- build / codec --------------------------------------------------------
/** Builds the encrypted kind-13302 list event from [entries], signed by [signer]. */
suspend fun build(
signer: NostrSigner,
@@ -81,13 +171,43 @@ object ConcordCommunityList {
return signer.sign(createdAt, ConcordCommunityListEvent.KIND, emptyArray(), content)
}
/** Serializes [entries] to the plaintext JSON that gets NIP-44 self-encrypted. */
fun encode(entries: List<ConcordCommunityListEntry>): String = ConcordJson.instance.encodeToString(ListSerializer(ConcordCommunityListEntry.serializer()), entries)
/** Serializes [entries] to the plaintext JSON document that gets NIP-44 self-encrypted. */
fun encode(entries: List<ConcordCommunityListEntry>): String {
val doc =
CommunityListDoc(
entries =
entries.map { e ->
val jm = e.toJoinMaterial()
CommunityListEntryWire(
communityId = e.id,
seed = jm,
current = jm,
addedAt = e.addedAt,
)
},
tombstones = emptyList(),
)
return ConcordJson.instance.encodeToString(CommunityListDoc.serializer(), doc)
}
/** Parses the decrypted plaintext JSON back into entries, or empty on failure. */
/**
* Parses the decrypted plaintext JSON document back into live entries, or empty on
* failure. An entry is live unless a tombstone for the same community removed it
* strictly after it was added; hydration prefers `current`, falling back to `seed`.
*/
fun decode(json: String): List<ConcordCommunityListEntry> =
try {
ConcordJson.instance.decodeFromString(ListSerializer(ConcordCommunityListEntry.serializer()), json)
val doc = ConcordJson.instance.decodeFromString(CommunityListDoc.serializer(), json)
val latestRemoval = HashMap<String, Long>()
for (t in doc.tombstones) {
val prev = latestRemoval[t.communityId]
if (prev == null || t.removedAt > prev) latestRemoval[t.communityId] = t.removedAt
}
doc.entries.mapNotNull { e ->
val removedAt = latestRemoval[e.communityId]
if (removedAt != null && e.addedAt <= removedAt) return@mapNotNull null
(e.current ?: e.seed)?.toEntry(e.addedAt)
}
} catch (_: Exception) {
emptyList()
}
@@ -69,6 +69,70 @@ class ConcordCommunityListTest {
assertTrue(ConcordCommunityList.parse(event, other).isEmpty()) // wrong key ⇒ nothing
}
@Test
fun decodesArmadaWireDocument() {
// A document as Soapbox Armada writes it (communityList.ts): {entries:[{community_id,
// seed, current, added_at}], tombstones:[]} with snake_case JoinMaterial.
val json =
"""
{
"entries": [
{
"community_id": "${"11".repeat(32)}",
"seed": {
"community_id": "${"11".repeat(32)}",
"owner": "${"0f".repeat(32)}",
"owner_salt": "${"aa".repeat(32)}",
"community_root": "${"bb".repeat(32)}",
"root_epoch": 0,
"channels": [],
"relays": ["wss://relay.ditto.pub"],
"name": "Soapbox"
},
"current": {
"community_id": "${"11".repeat(32)}",
"owner": "${"0f".repeat(32)}",
"owner_salt": "${"aa".repeat(32)}",
"community_root": "${"cc".repeat(32)}",
"root_epoch": 2,
"channels": [
{ "id": "${"ee".repeat(32)}", "key": "${"dd".repeat(32)}", "epoch": 2, "name": "secret" }
],
"relays": ["wss://relay.ditto.pub"],
"name": "Soapbox",
"held_roots": [ { "epoch": 1, "key": "${"bb".repeat(32)}" } ]
},
"added_at": 1700000000000
}
],
"tombstones": []
}
""".trimIndent()
val entries = ConcordCommunityList.decode(json)
assertEquals(1, entries.size)
val e = entries[0]
assertEquals("11".repeat(32), e.id)
assertEquals("Soapbox", e.name)
assertEquals("cc".repeat(32), e.root) // hydrated from `current`, not `seed`
assertEquals(2L, e.rootEpoch)
assertEquals(1700000000000L, e.addedAt)
assertEquals(listOf("wss://relay.ditto.pub"), e.relays)
assertEquals(1, e.privateChannels.size)
assertEquals("ee".repeat(32), e.privateChannels[0].channelId)
assertEquals("secret", e.privateChannels[0].name)
assertEquals(1, e.heldRoots.size)
assertEquals(1L, e.heldRoots[0].epoch)
}
@Test
fun tombstoneAfterAddDropsEntry() {
val jm = """{"community_id":"${"11".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"aa".repeat(32)}","community_root":"${"bb".repeat(32)}","root_epoch":0,"channels":[],"relays":[],"name":"Gone"}"""
val json =
"""{"entries":[{"community_id":"${"11".repeat(32)}","seed":$jm,"current":$jm,"added_at":100}],"tombstones":[{"community_id":"${"11".repeat(32)}","removed_at":200}]}"""
assertTrue(ConcordCommunityList.decode(json).isEmpty()) // removed after add ⇒ not live
}
@Test
fun mergeKeepsFreshestEpochPerCommunity() {
val a = listOf(entry("11".repeat(32), "Old", epoch = 1))