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 // Build filter for the member's KeyPackages
val filter = manager.subscriptionManager.keyPackageFilter(memberPubKey) val filter = manager.subscriptionManager.keyPackageFilter(memberPubKey)
// KeyPackages are published by the invitee to *their own* outbox // Per MIP-00, invitees advertise the relays that host their
// (see publishMarmotKeyPackage), so look there first. Union with // KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look
// our own outbox so we still find packages that ended up on a // there first, then fall back to the invitee's NIP-65 outbox
// shared relay, and as a fallback when we don't know the // (where KeyPackages typically also land), and finally union
// invitee's outbox yet. // with our own outbox so we still find packages that ended up
// on a shared relay.
val myOutbox = outboxRelays.flow.value 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 = val memberOutbox =
cache cache
.getOrCreateUser(memberPubKey) .getOrCreateUser(memberPubKey)
.outboxRelays() .outboxRelays()
?.toSet() ?.toSet()
.orEmpty() .orEmpty()
val fetchRelays = memberOutbox + myOutbox val fetchRelays = memberKeyPackageRelays + memberOutbox + myOutbox
Log.d("MarmotDbg") { Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " + "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 // 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. * Publish or rotate KeyPackage events.
*/ */
@@ -1946,13 +1969,13 @@ class Account(
val manager = marmotManager ?: return val manager = marmotManager ?: return
if (!isWriteable()) return if (!isWriteable()) return
val relays = outboxRelays.flow.value.toList() val relays = keyPackagePublishRelays()
if (manager.needsKeyPackageRotation()) { if (manager.needsKeyPackageRotation()) {
val rotatedEvents = manager.rotateConsumedKeyPackages(relays) val rotatedEvents = manager.rotateConsumedKeyPackages(relays.toList())
rotatedEvents.forEach { event -> rotatedEvents.forEach { event ->
cache.justConsumeMyOwnEvent(event) cache.justConsumeMyOwnEvent(event)
client.publish(event, outboxRelays.flow.value) client.publish(event, relays)
} }
} }
} }
@@ -1964,16 +1987,16 @@ class Account(
val manager = marmotManager ?: return val manager = marmotManager ?: return
if (!isWriteable()) return if (!isWriteable()) return
val relays = outboxRelays.flow.value.toList() val relays = keyPackagePublishRelays()
Log.d("MarmotDbg") { Log.d("MarmotDbg") {
"publishMarmotKeyPackage: generating + publishing KeyPackage event → ${relays.size} relay(s): ${relays.map { it.url }}" "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") { Log.d("MarmotDbg") {
"publishMarmotKeyPackage: signed kind:${event.kind} id=${event.id.take(8)}… authored=${event.pubKey.take(8)}" "publishMarmotKeyPackage: signed kind:${event.kind} id=${event.id.take(8)}… authored=${event.pubKey.take(8)}"
} }
cache.justConsumeMyOwnEvent(event) 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.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent 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.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -53,6 +54,7 @@ val AccountInfoAndListsFromKeyKinds =
StatusEvent.KIND, StatusEvent.KIND,
AdvertisedRelayListEvent.KIND, AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND, ChatMessageRelayListEvent.KIND,
KeyPackageRelayListEvent.KIND,
SearchRelayListEvent.KIND, SearchRelayListEvent.KIND,
FileServersEvent.KIND, FileServersEvent.KIND,
BlossomServersEvent.KIND, BlossomServersEvent.KIND,
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata 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.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -47,6 +48,7 @@ val BasicAccountInfoKinds =
ContactListEvent.KIND, ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND, AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND, ChatMessageRelayListEvent.KIND,
KeyPackageRelayListEvent.KIND,
SearchRelayListEvent.KIND, SearchRelayListEvent.KIND,
FileServersEvent.KIND, FileServersEvent.KIND,
BlossomServersEvent.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.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast 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.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -41,6 +42,7 @@ val UserMetadataForKeyKinds =
StatusEvent.KIND, StatusEvent.KIND,
AdvertisedRelayListEvent.KIND, AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND, ChatMessageRelayListEvent.KIND,
KeyPackageRelayListEvent.KIND,
) )
fun filterUserMetadataForKey( fun filterUserMetadataForKey(
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.model.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.ui.navigation.routes.Route 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.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -293,6 +294,7 @@ class AccountSessionManager(
accountSettings.backupContactList?.let { client.publish(it, toPost) } accountSettings.backupContactList?.let { client.publish(it, toPost) }
accountSettings.backupNIP65RelayList?.let { client.publish(it, toPost) } accountSettings.backupNIP65RelayList?.let { client.publish(it, toPost) }
accountSettings.backupDMRelayList?.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.backupSearchRelayList?.let { client.publish(it, toPost) }
accountSettings.backupIndexRelayList?.let { client.publish(it, toPost) } accountSettings.backupIndexRelayList?.let { client.publish(it, toPost) }
accountSettings.backupRelayFeedsList?.let { client.publish(it, toPost) } accountSettings.backupRelayFeedsList?.let { client.publish(it, toPost) }
@@ -317,6 +319,10 @@ class AccountSessionManager(
), ),
backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner), backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner),
backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, 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), backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), tempSigner),
backupIndexRelayList = IndexerRelayListEvent.create(DefaultIndexerRelayList.toList(), tempSigner), backupIndexRelayList = IndexerRelayListEvent.create(DefaultIndexerRelayList.toList(), tempSigner),
backupChannelList = ChannelListEvent.create(emptyList(), DefaultChannels, tempSigner), backupChannelList = ChannelListEvent.create(emptyList(), DefaultChannels, tempSigner),
@@ -1471,6 +1471,27 @@ class AccountViewModel(
suspend fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage() 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) { suspend fun leaveMarmotGroup(nostrGroupId: String) {
val relays = marmotGroupRelays(nostrGroupId) val relays = marmotGroupRelays(nostrGroupId)
account.leaveMarmotGroup(nostrGroupId, relays) 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.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -55,44 +57,53 @@ fun CreateGroupScreen(
) { ) {
var groupName by remember { mutableStateOf("") } var groupName by remember { mutableStateOf("") }
var isCreating by remember { mutableStateOf(false) } var isCreating by remember { mutableStateOf(false) }
var showKeyPackageRelayDialog by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current 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( Scaffold(
topBar = { topBar = {
CreatingTopBar( CreatingTopBar(
onCancel = { nav.popBack() }, onCancel = { nav.popBack() },
onPost = { onPost = {
isCreating = true if (!accountViewModel.hasKeyPackageRelayList()) {
scope.launch(Dispatchers.IO) { showKeyPackageRelayDialog = true
try { } else {
val nostrGroupId = RandomInstance.bytes(32).toHexKey() proceedWithCreate()
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()
}
}
} }
}, },
isActive = { !isCreating }, 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.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -70,6 +71,12 @@ class KeyPackageRelayListEvent(
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
): KeyPackageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "") ): 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( suspend fun updateRelayList(
earlierVersion: KeyPackageRelayListEvent, earlierVersion: KeyPackageRelayListEvent,
relays: List<NormalizedRelayUrl>, relays: List<NormalizedRelayUrl>,