fix(cli): apply Marmot relay-routing rules across every marmot command

The first pass only fixed `group add`. Auditing the rest of the CLI
against MIP-00..03 turned up three more spots where `amy` either
queried the wrong relays or silently skipped a required advertisement:

1. `marmot key-package check <npub>` used `anyRelays()` — i.e. the
   inviter's configured relays — so checking for a KeyPackage on a
   user who advertises `kind:10051` somewhere we don't know about
   always returned `not_found`. Now runs RecipientRelayFetcher against
   bootstrap seeds and fetches from the union of (target kind:10051,
   target kind:10002 write, bootstrap). Emits `found_on` so callers
   can see which relay served the hit.

2. `await key-package <npub>` had the same bug inside the poll loop.
   Resolved once up front, then the loop fetches from the target's
   advertised relays every tick. Throws an `AwaitTimeout` early if no
   relays can be discovered at all, instead of silently polling void.

3. `relay publish-lists` published kind:10002 + kind:10050 but never
   kind:10051. Per MIP-00 the KeyPackage Relay List is how other
   Marmot clients discover where our KPs live — without it the
   `key_package` bucket on disk is invisible to anyone else. Now also
   publishes kind:10051; falls back to the NIP-65 set if the bucket
   is empty so we never advertise an empty list. (`amy create`
   already publishes it via AccountBootstrapEvents.)

Docs: cli/README adds a "Relay routing" section that lists the exact
relay set used for publish vs fetch of every Marmot event kind, plus
the bootstrap-pool definition, so agents + interop-test authors can
reason about cross-user reachability without reading the code.
This commit is contained in:
Claude
2026-04-22 22:15:10 +00:00
parent 5289fa3398
commit 0657f6e1c7
4 changed files with 100 additions and 22 deletions
+31 -4
View File
@@ -114,9 +114,9 @@ Run `amy --help` for the canonical list. As of today:
| `whoami` | Print the identity stored in `--data-dir`. |
| `relay add URL [--type T]` | `T = nip65 \| inbox \| key_package \| all`. |
| `relay list` | Dump configured relays by bucket. |
| `relay publish-lists` | Publish kind:10002 (NIP-65) + kind:10050 (DM inbox). |
| `marmot key-package publish` | Publish a fresh MLS KeyPackage (kind:30443). |
| `marmot key-package check NPUB` | Fetch someone else's KeyPackage from their advertised relays. |
| `relay publish-lists` | Publish kind:10002 (NIP-65) + kind:10050 (DM inbox) + kind:10051 (KeyPackage relay list). |
| `marmot key-package publish` | Publish a fresh MLS KeyPackage (kind:30443) to the configured `key_package` bucket (fallback: NIP-65 outbox). |
| `marmot key-package check NPUB` | Look up NPUB's kind:10051 / kind:10002 on bootstrap relays, then fetch their KeyPackage from those relays. |
| `marmot group create [--name NAME]` | New empty group with you as sole admin. |
| `marmot group list` | All groups you're a member of. |
| `marmot group show GID` | Full group state (members, admins, epoch, metadata). |
@@ -130,7 +130,7 @@ Run `amy --help` for the canonical list. As of today:
| `marmot group leave GID` | Self-remove. |
| `marmot message send GID TEXT` | Publish a kind:9 inner event into the group. |
| `marmot message list GID [--limit N]` | Decrypted inner events, oldest first. |
| `marmot await key-package NPUB` | Block until a KeyPackage is seen on relays. |
| `marmot await key-package NPUB` | Block until a KeyPackage is seen on NPUB's advertised relays (kind:10051 / kind:10002). |
| `marmot await group --name NAME` | Block until we're added to a group with that name. |
| `marmot await member GID NPUB` | Block until NPUB is in GID's member set. |
| `marmot await admin GID NPUB` | Block until NPUB is an admin of GID. |
@@ -150,6 +150,33 @@ itself crashed".
---
## Relay routing
Amy follows the Marmot protocol's per-event routing rules so two users
with completely disjoint relay configurations can still marmot each
other. No event ever ships blindly to "our configured relays" — Amy
looks up the right relay set per event per recipient.
| Event | Publish to | Fetch from |
|---|---|---|
| kind:30443 (our own KeyPackage) | `key_package` bucket → NIP-65 outbox → any configured | — |
| kind:30443 (someone else's KeyPackage) | — | Their kind:10051 → their kind:10002 write → our bootstrap pool |
| kind:10051 / 10050 / 10002 (our own lists) | All configured relays (broadcast) | — |
| kind:10051 / 10050 / 10002 (someone else's) | — | Our bootstrap pool = configured relays ∪ Amethyst defaults |
| kind:1059 Welcome gift wrap (kind:444 inside) | Recipient's kind:10050 → their kind:10002 read → `DefaultDMRelayList` → our outbox | — |
| kind:1059 gift wraps addressed to us | — | Our kind:10050 |
| kind:445 Group Event (Commit / Proposal / chat) | Group's MIP-01 `relays` field | Same |
**Bootstrap pool**: when Amy needs to discover a user it's never talked
to, it queries `configured relays ∪ Amethyst's default NIP-65 set ∪
Amethyst's default DM-inbox set`. These defaults come from
`commons.defaults.AmethystDefaults` and match what the Android/Desktop
UI publishes to on first run, so any fresh Amethyst account is
reachable via the bootstrap pool even before Amy has seen any of their
events.
---
## Data-dir layout
```
@@ -26,7 +26,9 @@ import com.vitorpamplona.amethyst.cli.AwaitTimeout
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.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import kotlinx.coroutines.delay
@@ -65,19 +67,39 @@ object AwaitCommands {
ctx.prepare()
val target = ctx.requireUserHex(rest[0])
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(target)
// MIP-00: target's KeyPackages live on the relays advertised in
// their kind:10051 (fallback: kind:10002 write). Resolve the
// right relay set once up front against bootstrap seeds; the
// polling loop then hits those relays on every tick. If the
// target hasn't published either list yet, fall back to the
// bootstrap pool so the loop still has something to query.
val seed = ctx.bootstrapRelays()
val lists = RecipientRelayFetcher.fetchRelayLists(ctx.client, target, seed)
val relays =
KeyPackageFetcher.fetchRelaysFor(
targetKeyPackageRelays = lists.keyPackage,
targetOutbox = lists.nip65Write(),
myOutbox = seed,
)
if (relays.isEmpty()) {
throw AwaitTimeout("no relays to query for $target (configure relays or bootstrap defaults first)")
}
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
val relays = ctx.anyRelays()
if (relays.isNotEmpty()) {
val event =
ctx.client.fetchFirst(
filters = relays.associateWith { listOf(filter) },
timeoutMs = 3_000,
)
if (event is KeyPackageEvent) {
Json.writeLine(mapOf("event_id" to event.id, "author" to event.pubKey))
return 0
}
val event =
ctx.client.fetchFirst(
filters = relays.associateWith { listOf(filter) },
timeoutMs = 3_000,
)
if (event is KeyPackageEvent) {
Json.writeLine(
mapOf(
"event_id" to event.id,
"author" to event.pubKey,
"found_on" to relays.map { it.url },
),
)
return 0
}
delay(2_000)
}
@@ -23,6 +23,8 @@ 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
object KeyPackageCommands {
suspend fun dispatch(
@@ -69,15 +71,26 @@ object KeyPackageCommands {
try {
ctx.prepare()
val targetHex = ctx.requireUserHex(rest[0])
// CLI doesn't (yet) cache target's kind:10051/10002 — just ask every
// configured relay. Amethyst, which does cache those, passes them in.
// Per MIP-00: a user's KeyPackages live on the relays advertised
// in their kind:10051 event (fallback: kind:10002 write marker).
// Look those up first from bootstrap seeds so `check` works even
// when the target and inviter share no relays.
val seed = ctx.bootstrapRelays()
if (seed.isEmpty()) return Json.error("no_relays", "configure relays first")
val recipient = RecipientRelayFetcher.fetchRelayLists(ctx.client, targetHex, seed)
val relays =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchRelaysFor(emptySet(), emptySet(), ctx.anyRelays())
if (relays.isEmpty()) return Json.error("no_relays", "configure relays first")
KeyPackageFetcher.fetchRelaysFor(
targetKeyPackageRelays = recipient.keyPackage,
targetOutbox = recipient.nip65Write(),
myOutbox = seed,
)
val event =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchKeyPackage(ctx.client, targetHex, relays, timeoutMs = 10_000)
KeyPackageFetcher.fetchKeyPackage(
client = ctx.client,
targetPubKey = targetHex,
relays = relays,
timeoutMs = 10_000,
)
if (event == null) {
return Json.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)")
}
@@ -88,6 +101,7 @@ object KeyPackageCommands {
"kind" to event.kind,
"created_at" to event.createdAt,
"has_content" to event.content.isNotBlank(),
"found_on" to relays.map { it.url },
),
)
return 0
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
@@ -86,23 +87,37 @@ object RelayCommands {
ctx.prepare()
val nip65Relays = ctx.relays.normalized("nip65").toList()
val inboxRelays = ctx.relays.normalized("inbox").toList()
// MIP-00: other clients discover our KeyPackages by querying the
// relays advertised in our kind:10051 event. If no key_package
// bucket is configured, fall back to the NIP-65 set so we always
// publish a non-empty list — an empty 10051 would make us
// undiscoverable by other Marmot clients.
val keyPackageRelays =
ctx.relays
.normalized("key_package")
.ifEmpty { ctx.outboxRelays() }
.toList()
val nip65Infos = nip65Relays.map { AdvertisedRelayInfo(it, AdvertisedRelayType.BOTH) }
val nip65Event = AdvertisedRelayListEvent.create(nip65Infos, ctx.signer)
val inboxEvent = ChatMessageRelayListEvent.create(inboxRelays, ctx.signer)
val keyPackageListEvent = KeyPackageRelayListEvent.create(keyPackageRelays, ctx.signer)
val targets = ctx.anyRelays()
val nip65Result = ctx.publish(nip65Event, targets)
val inboxResult = ctx.publish(inboxEvent, targets)
val keyPackageListResult = ctx.publish(keyPackageListEvent, targets)
Json.writeLine(
mapOf(
"nip65_event_id" to nip65Event.id,
"inbox_event_id" to inboxEvent.id,
"key_package_list_event_id" to keyPackageListEvent.id,
"accepted_by" to
mapOf(
"nip65" to nip65Result.filterValues { it }.keys.map { it.url },
"inbox" to inboxResult.filterValues { it }.keys.map { it.url },
"key_package_list" to keyPackageListResult.filterValues { it }.keys.map { it.url },
),
),
)