feat: use MIP-00 KeyPackage Relay List for publish/fetch + seed default

- publishMarmotKeyPackage / publishMarmotKeyPackages now target the
  relays advertised in the user's kind:10051 KeyPackage Relay List
  (MIP-00), falling back to outbox relays when the list is empty.
- fetchKeyPackageAndAddMember looks up the invitee's kind:10051
  before falling back to their NIP-65 outbox.
- Add kind:10051 to user metadata + account info subscription filters
  so invitees' KeyPackage relay lists are cached in time for invites.
- Seed a default kind:10051 on signup from the default NIP-65 relay set
  and publish it alongside other account bootstrap events.
- Warn the user when creating a Marmot group without a kind:10051 list
  and offer to create one from their current outbox relays.
- Sync overload for KeyPackageRelayListEvent.create to support the
  signup-time temp signer.

https://claude.ai/code/session_01B7kTUFAPvWarWd6fvQKNBy
This commit is contained in:
Claude
2026-04-17 19:19:11 +00:00
parent c7a3c5c4a3
commit a3ca22e175
8 changed files with 166 additions and 43 deletions
@@ -1788,23 +1788,32 @@ class Account(
// Build filter for the member's KeyPackages
val filter = manager.subscriptionManager.keyPackageFilter(memberPubKey)
// KeyPackages are published by the invitee to *their own* outbox
// (see publishMarmotKeyPackage), so look there first. Union with
// our own outbox so we still find packages that ended up on a
// shared relay, and as a fallback when we don't know the
// invitee's outbox yet.
// Per MIP-00, invitees advertise the relays that host their
// KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look
// there first, then fall back to the invitee's NIP-65 outbox
// (where KeyPackages typically also land), and finally union
// with our own outbox so we still find packages that ended up
// on a shared relay.
val myOutbox = outboxRelays.flow.value
val memberKeyPackageRelays =
(
cache
.getAddressableNoteIfExists(
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
.createAddress(memberPubKey),
)?.event as? com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
)?.relays()?.toSet().orEmpty()
val memberOutbox =
cache
.getOrCreateUser(memberPubKey)
.outboxRelays()
?.toSet()
.orEmpty()
val fetchRelays = memberOutbox + myOutbox
val fetchRelays = memberKeyPackageRelays + memberOutbox + myOutbox
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
"(memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}"
"(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}"
}
// Query across the combined relay set
@@ -1939,6 +1948,20 @@ class Account(
}
}
/**
* Relays where this account publishes kind:30443 KeyPackage events.
*
* Per MIP-00, these should match the relays advertised in the user's
* kind:10051 KeyPackage Relay List so that other clients can discover
* and fetch them. Falls back to the standard outbox set when no list
* has been configured yet, since that's also where the user's other
* write-oriented events land.
*/
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> {
val list = keyPackageRelayList.flow.value
return if (list.isNotEmpty()) list else outboxRelays.flow.value
}
/**
* Publish or rotate KeyPackage events.
*/
@@ -1946,13 +1969,13 @@ class Account(
val manager = marmotManager ?: return
if (!isWriteable()) return
val relays = outboxRelays.flow.value.toList()
val relays = keyPackagePublishRelays()
if (manager.needsKeyPackageRotation()) {
val rotatedEvents = manager.rotateConsumedKeyPackages(relays)
val rotatedEvents = manager.rotateConsumedKeyPackages(relays.toList())
rotatedEvents.forEach { event ->
cache.justConsumeMyOwnEvent(event)
client.publish(event, outboxRelays.flow.value)
client.publish(event, relays)
}
}
}
@@ -1964,16 +1987,16 @@ class Account(
val manager = marmotManager ?: return
if (!isWriteable()) return
val relays = outboxRelays.flow.value.toList()
val relays = keyPackagePublishRelays()
Log.d("MarmotDbg") {
"publishMarmotKeyPackage: generating + publishing KeyPackage event → ${relays.size} relay(s): ${relays.map { it.url }}"
}
val event = manager.generateKeyPackageEvent(relays)
val event = manager.generateKeyPackageEvent(relays.toList())
Log.d("MarmotDbg") {
"publishMarmotKeyPackage: signed kind:${event.kind} id=${event.id.take(8)}… authored=${event.pubKey.take(8)}"
}
cache.justConsumeMyOwnEvent(event)
client.publish(event, outboxRelays.flow.value)
client.publish(event, relays)
}
/**
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metada
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -53,6 +54,7 @@ val AccountInfoAndListsFromKeyKinds =
StatusEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
KeyPackageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -47,6 +48,7 @@ val BasicAccountInfoKinds =
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
KeyPackageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -41,6 +42,7 @@ val UserMetadataForKeyKinds =
StatusEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
KeyPackageRelayListEvent.KIND,
)
fun filterUserMetadataForKey(
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.model.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -293,6 +294,7 @@ class AccountSessionManager(
accountSettings.backupContactList?.let { client.publish(it, toPost) }
accountSettings.backupNIP65RelayList?.let { client.publish(it, toPost) }
accountSettings.backupDMRelayList?.let { client.publish(it, toPost) }
accountSettings.backupKeyPackageRelayList?.let { client.publish(it, toPost) }
accountSettings.backupSearchRelayList?.let { client.publish(it, toPost) }
accountSettings.backupIndexRelayList?.let { client.publish(it, toPost) }
accountSettings.backupRelayFeedsList?.let { client.publish(it, toPost) }
@@ -317,6 +319,10 @@ class AccountSessionManager(
),
backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner),
backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner),
// MIP-00: advertise the default outbox relays as KeyPackage hosts
// so other users can discover and fetch this account's KeyPackage
// events without having to guess where they were published.
backupKeyPackageRelayList = KeyPackageRelayListEvent.create(DefaultNIP65RelaySet.toList(), tempSigner),
backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), tempSigner),
backupIndexRelayList = IndexerRelayListEvent.create(DefaultIndexerRelayList.toList(), tempSigner),
backupChannelList = ChannelListEvent.create(emptyList(), DefaultChannels, tempSigner),
@@ -1471,6 +1471,27 @@ class AccountViewModel(
suspend fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage()
/**
* Whether this account has a kind:10051 KeyPackage Relay List (MIP-00)
* advertising where it publishes KeyPackages.
*/
fun hasKeyPackageRelayList(): Boolean =
account.keyPackageRelayList.flow.value
.isNotEmpty()
/**
* Publishes a kind:10051 KeyPackage Relay List seeded from the account's
* current outbox relays. Used when the user opts in from the
* "Create Group" warning dialog.
*/
suspend fun saveKeyPackageRelayListFromOutbox() {
val outbox =
account.outboxRelays.flow.value
.toList()
if (outbox.isEmpty()) return
account.saveKeyPackageRelayList(outbox)
}
suspend fun leaveMarmotGroup(nostrGroupId: String) {
val relays = marmotGroupRelays(nostrGroupId)
account.leaveMarmotGroup(nostrGroupId, relays)
@@ -26,10 +26,12 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -55,44 +57,53 @@ fun CreateGroupScreen(
) {
var groupName by remember { mutableStateOf("") }
var isCreating by remember { mutableStateOf(false) }
var showKeyPackageRelayDialog by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
fun proceedWithCreate() {
isCreating = true
scope.launch(Dispatchers.IO) {
try {
val nostrGroupId = RandomInstance.bytes(32).toHexKey()
accountViewModel.createMarmotGroup(nostrGroupId)
// Always commit an initial metadata extension so that
// (a) the name (if any) is persisted in MLS extensions
// and survives app restarts,
// (b) the inviter's outbox relays land in the group
// metadata so every member ends up with the same
// canonical relay set for kind:445 — without this,
// invitees would never receive the group's messages.
// Both are handled inside `updateMarmotGroupMetadata`.
accountViewModel.updateMarmotGroupMetadata(
nostrGroupId = nostrGroupId,
name = groupName.trim(),
description = "",
)
nav.nav(Route.MarmotGroupChat(nostrGroupId))
} catch (e: Exception) {
isCreating = false
launch(Dispatchers.Main) {
Toast
.makeText(
context,
"Failed to create group: ${e.message}",
Toast.LENGTH_LONG,
).show()
}
}
}
}
Scaffold(
topBar = {
CreatingTopBar(
onCancel = { nav.popBack() },
onPost = {
isCreating = true
scope.launch(Dispatchers.IO) {
try {
val nostrGroupId = RandomInstance.bytes(32).toHexKey()
accountViewModel.createMarmotGroup(nostrGroupId)
// Always commit an initial metadata extension so that
// (a) the name (if any) is persisted in MLS extensions
// and survives app restarts,
// (b) the inviter's outbox relays land in the group
// metadata so every member ends up with the same
// canonical relay set for kind:445 — without this,
// invitees would never receive the group's messages.
// Both are handled inside `updateMarmotGroupMetadata`.
accountViewModel.updateMarmotGroupMetadata(
nostrGroupId = nostrGroupId,
name = groupName.trim(),
description = "",
)
nav.nav(Route.MarmotGroupChat(nostrGroupId))
} catch (e: Exception) {
isCreating = false
launch(Dispatchers.Main) {
Toast
.makeText(
context,
"Failed to create group: ${e.message}",
Toast.LENGTH_LONG,
).show()
}
}
if (!accountViewModel.hasKeyPackageRelayList()) {
showKeyPackageRelayDialog = true
} else {
proceedWithCreate()
}
},
isActive = { !isCreating },
@@ -129,4 +140,53 @@ fun CreateGroupScreen(
)
}
}
if (showKeyPackageRelayDialog) {
MissingKeyPackageRelayListDialog(
onConfirm = {
showKeyPackageRelayDialog = false
scope.launch(Dispatchers.IO) {
accountViewModel.saveKeyPackageRelayListFromOutbox()
}
proceedWithCreate()
},
onDismiss = {
showKeyPackageRelayDialog = false
proceedWithCreate()
},
onCancel = {
showKeyPackageRelayDialog = false
},
)
}
}
@Composable
private fun MissingKeyPackageRelayListDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("KeyPackage Relays not set") },
text = {
Text(
"You don't have a KeyPackage Relay List yet (MIP-00). " +
"This list tells other people where your KeyPackage is " +
"published so they can invite you to group chats.\n\n" +
"Use your current outbox relays for this?",
)
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Use outbox relays")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Skip for now")
}
},
)
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -70,6 +71,12 @@ class KeyPackageRelayListEvent(
createdAt: Long = TimeUtils.now(),
): KeyPackageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "")
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): KeyPackageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "")
suspend fun updateRelayList(
earlierVersion: KeyPackageRelayListEvent,
relays: List<NormalizedRelayUrl>,