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:
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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),
|
||||
|
||||
+21
@@ -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)
|
||||
|
||||
+90
-30
@@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+7
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user