From f6f2a34353cbe518ffd01b8d6eb3aa3fe32244bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 21:45:14 +0000 Subject: [PATCH 1/3] fix(cli): route Marmot welcome + KeyPackage fetch to the invitee's relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../com/vitorpamplona/amethyst/cli/Context.kt | 18 +++ .../cli/commands/GroupAddMemberCommand.kt | 82 +++++++++-- .../quartz/marmot/RecipientRelayFetcher.kt | 134 ++++++++++++++++++ 3 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index e2540ebbc..24e6c9dd0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -125,6 +125,24 @@ class Context( fun anyRelays(): Set = 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 = + 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. * diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt index 89e1650a6..effd7c9c7 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt @@ -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 [ ...]` — 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>() 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 = 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 }, ), ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt new file mode 100644 index 000000000..9014af621 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt @@ -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, + /** kind:10051 relays — where the user hosts MIP-00 KeyPackages. */ + val keyPackage: List, + /** 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 = nip65?.readRelaysNorm().orEmpty() + + /** Write-marker relays from kind:10002. Mirrors `User.outboxRelays()`. */ + fun nip65Write(): List = 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 = 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, + 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, + ) + } +} From 5289fa3398da91e35f2e176ebf41eec7cca12752 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 22:04:30 +0000 Subject: [PATCH 2/3] style(cli): import NormalizedRelayUrl + Amethyst defaults instead of inlining FQNs --- .../main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt | 6 ++++-- .../amethyst/cli/commands/GroupAddMemberCommand.kt | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 24e6c9dd0..b08b3236a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.cli import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore +import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.marmot.ingest import com.vitorpamplona.quartz.marmot.MarmotFilters @@ -139,8 +141,8 @@ class Context( fun bootstrapRelays(): Set = buildSet { addAll(anyRelays()) - addAll(com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet) - addAll(com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList) + addAll(DefaultNIP65RelaySet) + addAll(DefaultDMRelayList) } /** diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt index effd7c9c7..ab18c5556 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt @@ -23,8 +23,10 @@ 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.amethyst.commons.defaults.DefaultDMRelayList import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** * `group add [ ...]` — fetch each invitee's @@ -108,7 +110,7 @@ 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 welcomeTargets: Set = + val welcomeTargets: Set = if (welcomeDelivery != null) { // Welcome gift wrap (kind:1059 wrapping kind:444) must // land on a relay the invitee actually polls for their @@ -124,7 +126,7 @@ object GroupAddMemberCommand { buildSet { addAll(recipient.dmInboxOrFallback()) if (isEmpty()) { - addAll(com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList) + addAll(DefaultDMRelayList) } addAll(ctx.outboxRelays()) } From 0657f6e1c7b904d62d3a0adbc0cb318e8e5ed0ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 22:15:10 +0000 Subject: [PATCH 3/3] fix(cli): apply Marmot relay-routing rules across every marmot command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 ` 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. --- cli/README.md | 35 +++++++++++++-- .../amethyst/cli/commands/AwaitCommands.kt | 44 ++++++++++++++----- .../cli/commands/KeyPackageCommands.kt | 28 +++++++++--- .../amethyst/cli/commands/RelayCommands.kt | 15 +++++++ 4 files changed, 100 insertions(+), 22 deletions(-) diff --git a/cli/README.md b/cli/README.md index 6304b15d9..036071d29 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 ``` diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt index a741a536b..dec64ae71 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt @@ -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) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt index 3f647a4d2..91129df27 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt @@ -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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index 7a34df1c0..1cbe0ff56 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -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 }, ), ), )