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:
Claude
2026-04-22 21:45:14 +00:00
parent c82f4342c4
commit f6f2a34353
3 changed files with 222 additions and 12 deletions
@@ -125,6 +125,24 @@ class Context(
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.
*
@@ -23,13 +23,20 @@ package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
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
* KeyPackage from the union of (our relays + any known KeyPackage relays
* for them) and run the full add-member flow for each one: build commit,
* publish commit to the group's relays, then wrap + publish the Welcome
* gift wrap.
* KeyPackage from the union of (their advertised KeyPackage relays +
* their NIP-65 outbox + our bootstrap relays) and run the full add-member
* flow for each one: build commit, publish commit to the group's relays,
* 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 {
suspend fun run(
@@ -50,15 +57,42 @@ object GroupAddMemberCommand {
val invitees = rest.drop(1).map { ctx.requireUserHex(it) }
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?>>()
for (pub in invitees) {
val relays =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchRelaysFor(emptySet(), emptySet(), ctx.anyRelays())
// Discover where *this* invitee actually reads from. Without
// this the inviter can only broadcast to their own relays,
// 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 =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchKeyPackage(ctx.client, pub, relays, timeoutMs = 10_000)
KeyPackageFetcher.fetchKeyPackage(
client = ctx.client,
targetPubKey = pub,
relays = kpRelays,
timeoutMs = 10_000,
)
if (kpEvent == null) {
report.add(mapOf("pubkey" to pub, "status" to "no_key_package"))
continue
@@ -74,10 +108,32 @@ object GroupAddMemberCommand {
// Order matters: commit first (so invitee doesn't join at a future epoch),
// then welcome.
val commitAck = ctx.publish(commitEvent.signedEvent, groupRelays)
val welcomeAck =
val welcomeTargets: Set<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl> =
if (welcomeDelivery != null) {
val inbox = ctx.inboxRelays().ifEmpty { ctx.outboxRelays() }
ctx.publish(welcomeDelivery.giftWrapEvent, inbox)
// Welcome gift wrap (kind:1059 wrapping kind:444) must
// 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 {
emptyMap()
}
@@ -91,6 +147,8 @@ object GroupAddMemberCommand {
"welcome_event_id" to welcomeDelivery?.giftWrapEvent?.id,
"commit_accepted_by" to commitAck.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 },
),
)
}