mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
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:
+31
-4
@@ -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 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user