fix(cli): route Marmot welcome + KeyPackage fetch to the invitee's relays
`amy marmot group add` previously sent the Welcome gift wrap to the inviter's own kind:10050 (ctx.inboxRelays()) and fetched the invitee's KeyPackage from only the inviter's configured relays. Two users with disjoint relay configurations could never successfully marmot each other — the welcome landed somewhere the invitee never polled. Mirror the Android Account.addMarmotGroupMember flow in the CLI: - New RecipientRelayFetcher in quartz/marmot one-shot-drains a target user's kind:10050, kind:10051 and kind:10002 from a seed relay set. Replaceable-event semantics: newest created_at per kind wins. Guards against relays echoing events authored by someone else. - Context.bootstrapRelays() unions the inviter's configured relays with AmethystDefaults (DefaultNIP65RelaySet + DefaultDMRelayList) so we can discover strangers even when nothing overlaps with our own config. - GroupAddMemberCommand now per-invitee: looks up their relay lists; passes kind:10051 + kind:10002 write + bootstrap to KeyPackageFetcher; publishes the welcome to kind:10050 (fallback to NIP-65 read, then DefaultDMRelayList, then our outbox as belt-and-braces). Group commits/messages (kind:445) continue to use the group's MIP-01 relay set — those were already correct. Exposes welcome_targets and key_package_relays in the JSON output so callers can verify routing.
This commit is contained in:
@@ -125,6 +125,24 @@ class Context(
|
|||||||
|
|
||||||
fun anyRelays(): Set<NormalizedRelayUrl> = relays.normalized("all")
|
fun anyRelays(): Set<NormalizedRelayUrl> = relays.normalized("all")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed relays for "look up someone we know nothing about" queries —
|
||||||
|
* fetching another user's kind:10002 / 10050 / 10051 / 30443 before we
|
||||||
|
* can deliver something to them.
|
||||||
|
*
|
||||||
|
* Strategy: union our own configured relays with Amethyst's hard-coded
|
||||||
|
* defaults (DefaultNIP65RelaySet + DefaultDMRelayList). The defaults are
|
||||||
|
* what every fresh Amethyst account publishes to first, so they're the
|
||||||
|
* most reliable place to find a stranger's replaceable events even when
|
||||||
|
* we and they have completely disjoint relay configurations.
|
||||||
|
*/
|
||||||
|
fun bootstrapRelays(): Set<NormalizedRelayUrl> =
|
||||||
|
buildSet {
|
||||||
|
addAll(anyRelays())
|
||||||
|
addAll(com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet)
|
||||||
|
addAll(com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish an event to the given relays and wait for OK confirmations.
|
* Publish an event to the given relays and wait for OK confirmations.
|
||||||
*
|
*
|
||||||
|
|||||||
+70
-12
@@ -23,13 +23,20 @@ package com.vitorpamplona.amethyst.cli.commands
|
|||||||
import com.vitorpamplona.amethyst.cli.Context
|
import com.vitorpamplona.amethyst.cli.Context
|
||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Json
|
import com.vitorpamplona.amethyst.cli.Json
|
||||||
|
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `group add <group_id> <npub> [<npub> ...]` — fetch each invitee's
|
* `group add <group_id> <npub> [<npub> ...]` — fetch each invitee's
|
||||||
* KeyPackage from the union of (our relays + any known KeyPackage relays
|
* KeyPackage from the union of (their advertised KeyPackage relays +
|
||||||
* for them) and run the full add-member flow for each one: build commit,
|
* their NIP-65 outbox + our bootstrap relays) and run the full add-member
|
||||||
* publish commit to the group's relays, then wrap + publish the Welcome
|
* flow for each one: build commit, publish commit to the group's relays,
|
||||||
* gift wrap.
|
* then wrap + publish the Welcome gift wrap to the invitee's DM inbox
|
||||||
|
* (kind:10050) with their NIP-65 read relays as a fallback.
|
||||||
|
*
|
||||||
|
* Two different users could have completely disjoint relay configurations
|
||||||
|
* and still successfully marmot each other — we discover where each
|
||||||
|
* invitee is actually listening before routing anything to them.
|
||||||
*/
|
*/
|
||||||
object GroupAddMemberCommand {
|
object GroupAddMemberCommand {
|
||||||
suspend fun run(
|
suspend fun run(
|
||||||
@@ -50,15 +57,42 @@ object GroupAddMemberCommand {
|
|||||||
val invitees = rest.drop(1).map { ctx.requireUserHex(it) }
|
val invitees = rest.drop(1).map { ctx.requireUserHex(it) }
|
||||||
|
|
||||||
val groupRelays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
val groupRelays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||||
|
// Computed once: the seed relays we query for any stranger's
|
||||||
|
// published relay-routing events. Union of our own configured
|
||||||
|
// relays and Amethyst's hard-coded defaults so we stay useful
|
||||||
|
// when an invitee shares nothing with us but used Amethyst to
|
||||||
|
// bootstrap.
|
||||||
|
val seed = ctx.bootstrapRelays()
|
||||||
val report = mutableListOf<Map<String, Any?>>()
|
val report = mutableListOf<Map<String, Any?>>()
|
||||||
|
|
||||||
for (pub in invitees) {
|
for (pub in invitees) {
|
||||||
val relays =
|
// Discover where *this* invitee actually reads from. Without
|
||||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
// this the inviter can only broadcast to their own relays,
|
||||||
.fetchRelaysFor(emptySet(), emptySet(), ctx.anyRelays())
|
// which silently fails the moment the two users have
|
||||||
|
// disjoint relay configs.
|
||||||
|
val recipient =
|
||||||
|
RecipientRelayFetcher.fetchRelayLists(
|
||||||
|
client = ctx.client,
|
||||||
|
pubKey = pub,
|
||||||
|
seedRelays = seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyPackage discovery (MIP-00): prefer the invitee's own
|
||||||
|
// kind:10051, then their kind:10002 write marker, then our
|
||||||
|
// bootstrap pool as a last-resort fallback.
|
||||||
|
val kpRelays =
|
||||||
|
KeyPackageFetcher.fetchRelaysFor(
|
||||||
|
targetKeyPackageRelays = recipient.keyPackage,
|
||||||
|
targetOutbox = recipient.nip65Write(),
|
||||||
|
myOutbox = seed,
|
||||||
|
)
|
||||||
val kpEvent =
|
val kpEvent =
|
||||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
KeyPackageFetcher.fetchKeyPackage(
|
||||||
.fetchKeyPackage(ctx.client, pub, relays, timeoutMs = 10_000)
|
client = ctx.client,
|
||||||
|
targetPubKey = pub,
|
||||||
|
relays = kpRelays,
|
||||||
|
timeoutMs = 10_000,
|
||||||
|
)
|
||||||
if (kpEvent == null) {
|
if (kpEvent == null) {
|
||||||
report.add(mapOf("pubkey" to pub, "status" to "no_key_package"))
|
report.add(mapOf("pubkey" to pub, "status" to "no_key_package"))
|
||||||
continue
|
continue
|
||||||
@@ -74,10 +108,32 @@ object GroupAddMemberCommand {
|
|||||||
// Order matters: commit first (so invitee doesn't join at a future epoch),
|
// Order matters: commit first (so invitee doesn't join at a future epoch),
|
||||||
// then welcome.
|
// then welcome.
|
||||||
val commitAck = ctx.publish(commitEvent.signedEvent, groupRelays)
|
val commitAck = ctx.publish(commitEvent.signedEvent, groupRelays)
|
||||||
val welcomeAck =
|
val welcomeTargets: Set<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl> =
|
||||||
if (welcomeDelivery != null) {
|
if (welcomeDelivery != null) {
|
||||||
val inbox = ctx.inboxRelays().ifEmpty { ctx.outboxRelays() }
|
// Welcome gift wrap (kind:1059 wrapping kind:444) must
|
||||||
ctx.publish(welcomeDelivery.giftWrapEvent, inbox)
|
// land on a relay the invitee actually polls for their
|
||||||
|
// NIP-59 inbox. Priority:
|
||||||
|
// 1. kind:10050 (their explicit DM inbox)
|
||||||
|
// 2. kind:10002 read markers (NIP-65 fallback,
|
||||||
|
// matches User.dmInboxRelays())
|
||||||
|
// 3. Amethyst's DefaultDMRelayList (best-effort if
|
||||||
|
// the invitee has published nothing — freshly-
|
||||||
|
// bootstrapped Amethyst accounts listen on these)
|
||||||
|
// Our own outbox is added as belt-and-braces so we
|
||||||
|
// can re-ingest the welcome ourselves too.
|
||||||
|
buildSet {
|
||||||
|
addAll(recipient.dmInboxOrFallback())
|
||||||
|
if (isEmpty()) {
|
||||||
|
addAll(com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList)
|
||||||
|
}
|
||||||
|
addAll(ctx.outboxRelays())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
val welcomeAck =
|
||||||
|
if (welcomeDelivery != null && welcomeTargets.isNotEmpty()) {
|
||||||
|
ctx.publish(welcomeDelivery.giftWrapEvent, welcomeTargets)
|
||||||
} else {
|
} else {
|
||||||
emptyMap()
|
emptyMap()
|
||||||
}
|
}
|
||||||
@@ -91,6 +147,8 @@ object GroupAddMemberCommand {
|
|||||||
"welcome_event_id" to welcomeDelivery?.giftWrapEvent?.id,
|
"welcome_event_id" to welcomeDelivery?.giftWrapEvent?.id,
|
||||||
"commit_accepted_by" to commitAck.filterValues { it }.keys.map { it.url },
|
"commit_accepted_by" to commitAck.filterValues { it }.keys.map { it.url },
|
||||||
"welcome_accepted_by" to welcomeAck.filterValues { it }.keys.map { it.url },
|
"welcome_accepted_by" to welcomeAck.filterValues { it }.keys.map { it.url },
|
||||||
|
"welcome_targets" to welcomeTargets.map { it.url },
|
||||||
|
"key_package_relays" to kpRelays.map { it.url },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
/*
|
||||||
|
* 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.marmot
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||||
|
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot lookup of a user's published relay-routing events. Used by stateless
|
||||||
|
* callers (the `amy` CLI, automated agents) that don't keep a long-lived
|
||||||
|
* `LocalCache` around to subscribe to these events as they arrive.
|
||||||
|
*
|
||||||
|
* The Amethyst Android app does the equivalent via `cache.getOrCreateUser(pk)
|
||||||
|
* .dmInboxRelays()` / `.outboxRelays()` after its background subscriptions
|
||||||
|
* have populated the cache. The CLI doesn't have that pipeline, so it has to
|
||||||
|
* pull the same three replaceable events on demand whenever it needs to
|
||||||
|
* deliver something to someone whose relays it doesn't already know.
|
||||||
|
*
|
||||||
|
* Specifically required for Marmot (MIP-00..03):
|
||||||
|
* - kind:10050 — where to deliver the NIP-59 Welcome gift wrap.
|
||||||
|
* - kind:10051 — where to read MIP-00 KeyPackages from.
|
||||||
|
* - kind:10002 — fallback for both, plus a generic outbox/inbox advertisement.
|
||||||
|
*/
|
||||||
|
object RecipientRelayFetcher {
|
||||||
|
data class Lists(
|
||||||
|
/** kind:10050 relays — where the user's NIP-17 / NIP-59 inbox lives. */
|
||||||
|
val dmInbox: List<NormalizedRelayUrl>,
|
||||||
|
/** kind:10051 relays — where the user hosts MIP-00 KeyPackages. */
|
||||||
|
val keyPackage: List<NormalizedRelayUrl>,
|
||||||
|
/** Latest kind:10002 the user published (null if none seen). */
|
||||||
|
val nip65: AdvertisedRelayListEvent?,
|
||||||
|
) {
|
||||||
|
/** Read-marker relays from kind:10002. Mirrors `User.inboxRelays()`. */
|
||||||
|
fun nip65Read(): List<NormalizedRelayUrl> = nip65?.readRelaysNorm().orEmpty()
|
||||||
|
|
||||||
|
/** Write-marker relays from kind:10002. Mirrors `User.outboxRelays()`. */
|
||||||
|
fun nip65Write(): List<NormalizedRelayUrl> = nip65?.writeRelaysNorm().orEmpty()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where to deliver a NIP-59 gift wrap addressed to this user.
|
||||||
|
* Mirrors `User.dmInboxRelays()`: prefer kind:10050, fall back to the
|
||||||
|
* NIP-65 read marker.
|
||||||
|
*/
|
||||||
|
fun dmInboxOrFallback(): List<NormalizedRelayUrl> = dmInbox.ifEmpty { nip65Read() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain the latest kind:10050, 10051, 10002 events for [pubKey] from
|
||||||
|
* [seedRelays] and return them grouped by kind. Replaceable-event
|
||||||
|
* semantics: when a relay returns multiple versions, the newest
|
||||||
|
* `created_at` wins.
|
||||||
|
*
|
||||||
|
* Returns empty lists if no relays were given or no events arrived in
|
||||||
|
* time — callers decide how to fall back.
|
||||||
|
*/
|
||||||
|
suspend fun fetchRelayLists(
|
||||||
|
client: INostrClient,
|
||||||
|
pubKey: HexKey,
|
||||||
|
seedRelays: Set<NormalizedRelayUrl>,
|
||||||
|
timeoutMs: Long = 8_000L,
|
||||||
|
): Lists {
|
||||||
|
if (seedRelays.isEmpty()) return Lists(emptyList(), emptyList(), null)
|
||||||
|
|
||||||
|
val filter =
|
||||||
|
Filter(
|
||||||
|
kinds =
|
||||||
|
listOf(
|
||||||
|
ChatMessageRelayListEvent.KIND,
|
||||||
|
KeyPackageRelayListEvent.KIND,
|
||||||
|
AdvertisedRelayListEvent.KIND,
|
||||||
|
),
|
||||||
|
authors = listOf(pubKey),
|
||||||
|
)
|
||||||
|
|
||||||
|
val events =
|
||||||
|
client.fetchAll(
|
||||||
|
filters = seedRelays.associateWith { listOf(filter) },
|
||||||
|
timeoutMs = timeoutMs,
|
||||||
|
)
|
||||||
|
|
||||||
|
var dm: ChatMessageRelayListEvent? = null
|
||||||
|
var kp: KeyPackageRelayListEvent? = null
|
||||||
|
var nip65: AdvertisedRelayListEvent? = null
|
||||||
|
for (event in events) {
|
||||||
|
// Authors filter is enforced relay-side, but a malicious or buggy
|
||||||
|
// relay could echo something else. Keep the guard so we never
|
||||||
|
// route someone else's wrapped welcome to the wrong inbox.
|
||||||
|
if (event.pubKey != pubKey) continue
|
||||||
|
when (event) {
|
||||||
|
is ChatMessageRelayListEvent -> {
|
||||||
|
if (dm == null || event.createdAt > dm.createdAt) dm = event
|
||||||
|
}
|
||||||
|
|
||||||
|
is KeyPackageRelayListEvent -> {
|
||||||
|
if (kp == null || event.createdAt > kp.createdAt) kp = event
|
||||||
|
}
|
||||||
|
|
||||||
|
is AdvertisedRelayListEvent -> {
|
||||||
|
if (nip65 == null || event.createdAt > nip65.createdAt) nip65 = event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Lists(
|
||||||
|
dmInbox = dm?.relays().orEmpty(),
|
||||||
|
keyPackage = kp?.relays().orEmpty(),
|
||||||
|
nip65 = nip65,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user