Files
amethyst/tools/marmot-interop
Claude f08b010f50 fix(marmot,cli,interop): interop-compatible Marmot flows and harness correctness
Five protocol-level fixes and a batch of harness correctness fixes to get
the headless Marmot/Whitenoise interop harness from 1/13 to 5/13 passing
cleanly, with the remaining failures all rooted in wn's per-account
serial event-processor retry backoff (which drops undecryptable
pre-membership commits after several minutes) rather than amy behaviour.

quartz + commons
----------------
* MarmotGroupData: hold CURRENT_VERSION at 2. mdk-core (the Rust MLS
  engine used by whitenoise-rs) strict-rejects v3 payloads with
  `ExtensionFormatError("Trailing bytes in NostrGroupDataExtension")`
  — our v3 welcomes and GCE commits never apply, so every cross-client
  group flow dies at welcome processing. We still parse v3 happily on
  the way in; we just don't emit it until mdk publishes the
  forward-compat fix MIP-01 mandates.
* MarmotManager.updateGroupMetadata: MERGE extensions instead of
  REPLACING. RFC 9420 §12.1.7 says GCE proposals blow away the old
  extension list; callers that pass only [marmot_group_data] dropped
  [required_capabilities], which peers then reject. Preserve every slot
  except the one we're updating.
* MarmotManager.createGroup: new optional `initialMetadata` parameter
  that bakes MarmotGroupData into epoch-0 GroupContext.extensions
  directly. Without it, creators had to publish a pre-membership
  "bootstrap" commit that no later joiner could decrypt — each such
  peer then burned their retry budget on an undecryptable kind:445
  before seeing the real state. Threaded through MlsGroup.create /
  MlsGroupManager.createGroup.
* MarmotManager.mlsGroupIdHex: new translation helper so any code
  juggling the MIP-01 nostr_group_id (what amy indexes on) and the
  MLS GroupContext groupId (what mdk indexes on) can cross-reference
  them without reaching into MlsGroupManager directly.

amethyst module
---------------
* Account.leaveMarmotGroup: self-demote before SelfRemove per MIP-01,
  and promote a surviving member to admin first if the caller is the
  sole admin (otherwise we'd throw "admin depletion"). Matches the
  cli/GroupMembershipCommands.leave flow.

cli (amy)
---------
* Context.syncIncoming: don't advance `giftWrapSince` on empty polls
  (so the first-ever sync doesn't bump the cursor past every
  past-timestamped wrap we've ever been sent), subtract 2 days lookback
  when filtering (NIP-59 randomWithTwoDays gift wraps can have any
  createdAt in the last 48h), and only advance `groupSince` for groups
  we actually received events for.
* Context.syncIncoming: after ingest, if any Welcome consumed a
  KeyPackage, rotate and publish a fresh one immediately. MIP-00
  requires this — a KP can only be welcomed once and leaving the
  consumed one on relays just means later senders invite us with a
  bundle we no longer have private keys for.
* Context.resolveGroupId: accept either nostr_group_id (amy's primary
  key) or the MLS GroupContext groupId (what wn emits) on every verb
  that takes a group id. Wired through GroupAdd/Remove/Leave/Metadata
  /Read, Message send/list, and all the await* verbs so harness
  scripts never have to juggle both forms for a single group.
* GroupCreateCommand: bake initial metadata into epoch 0 (see the
  quartz change above). Dropped the now-redundant bootstrap commit
  publish and tightened the JSON output to include `mls_group_id`.
* GroupMembershipCommands.leave: self-demote admin before SelfRemove;
  promote an heir if we're the only admin, otherwise the GCE would
  deplete admins and the leave aborts.
* MarmotIngest.ingestGiftWrap: unwrap the sealed-rumor layer. NIP-59
  wrap is gift-wrap(kind:1059) → seal(kind:13) → rumor; the old code
  only unwrapped once and then checked `inner.kind == 444`, which is
  always false because inner is actually the seal. Unseal once more
  before the Welcome check. This single fix is what unsticks every
  amy-side Welcome ingestion.

wn harness patches + scripts
----------------------------
* whitenoise-defaults-env.patch: honour $WHITENOISE_DISCOVERY_RELAYS in
  `Relay::defaults()` (release builds otherwise bake damus.io / primal
  / nos.lol into every new account's NIP-65 / Inbox / KeyPackage
  lists, which breaks publishing and prevents the inbox subscription
  plane from ever reaching an operational state in a sandbox).
* setup.sh: sleep 2s after amy's initial kind:30443 publish so
  nostr-rs-relay has a chance to fsync before wn's first targeted
  discovery query. Without it wn's `keys check` races the relay's
  WAL flush and intermittently returns NotFound.
* lib.sh: peel wn's `{"result": …}` wrapper in `jq_group_id`,
  `wait_for_invite`, `wait_for_message`, `wait_for_member`. Post-v0.2
  wn `--json` output nests everything under `.result` (and
  `groups invites[]` nests further under `.group.mls_group_id`) —
  these helpers were still pattern-matching on the flat shape, so
  they returned empty strings for a perfectly good response.
* tests-{create,manage,extras}.sh: track both group IDs per test
  (amy's nostr + wn's MLS), pass each CLI the id it understands, and
  bump the post-commit wait timeouts to 90–120s so wn's exponential
  retry backoff has time to work through the pre-membership commits
  it can't decrypt and get to the ones it can.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
2026-04-22 00:28:45 +00:00
..

Marmot Interop Test Harness

Two flavours, same scenarios:

  • marmot-interop.sh — interactive. Drives B/C via wn and prompts the human to perform each Amethyst-side step in the mobile UI (Identity A). Use this for final UI verification.
  • marmot-interop-headless.sh — zero prompts. Drives A via the amy CLI (./gradlew :cli:installDist) and B/C via wn. Runs every scenario end-to-end and exits with a pass/fail summary. Use this for CI and for iterating on the Nostr/Marmot plumbing without needing to touch a phone.

Both harnesses validate Amethyst against whitenoise-rs (https://github.com/marmot-protocol/whitenoise-rs), the reference Rust implementation that powers the White Noise Flutter app. Every test records a pass/fail/skip result into a tab-separated log, and the summary is printed at the end of the run.

What gets tested

# Test Needs 3rd identity
01 KeyPackage publish & discovery (MIP-00)
02 Amethyst creates group, invites wn
03 wn creates group, invites Amethyst
04 3-member group, add-after-create yes
05 wn adds Amethyst to existing group yes
06 Member removal + forward secrecy yes
07 Group metadata rename round-trip (MIP-01)
08 Admin promote / demote yes
09 Reply / react / unreact (inner event kinds 9, 7)
10 Concurrent commits race
11 Leave group
12 Offline catch-up / replay
13 KeyPackage rotation
14 Push notifications (MIP-05) opt-in via --transponder

Prerequisites

On the machine that runs the harness:

  • Rust 1.90+ — install via https://rustup.rs
  • git, curl, jq — package manager
  • ~5 GB disk for the first-run build of wn + wnd
  • Public internet access (for the default relay set and fetching crates)

On the Android side:

  • Amethyst installed on an emulator or a physical device
  • The device must reach the same relays the harness uses (see below)

Quick start

cd tools/marmot-interop
./marmot-interop.sh

The script will, in order:

  1. Verify jq, git, cargo etc. are present.
  2. Clone whitenoise-rs into state/whitenoise-rs/ and build wn/wnd (release, --features cli). First build takes ~5 minutes; subsequent runs reuse the binaries.
  3. Launch two wnd daemons (one for Identity B, one for Identity C).
  4. Create Nostr identities for B and C, persist their npubs in state/run.env.
  5. Ask you to paste your Amethyst account npub (Identity A). This is cached for subsequent runs.
  6. Add the default public relays to both daemons and run a sanity check (publish a KP from B, fetch it from C).
  7. Print an Amethyst setup checklist — add the same relays to Amethyst, publish a KP, verify you are logged in with A.
  8. Run all 13 tests sequentially. Each test either:
    • runs wn commands fully automatically and asserts on JSON output, or
    • prints a "DO THIS IN AMETHYST" prompt and waits for you to press <Enter>, then verifies the Amethyst action via wn.
  9. Stop the daemons and print a results table.

Command-line flags

--local-relays    Use ws://localhost:8080 instead of the default public relays.
                  Required if the public relays reject kinds 444/445/30443.
                  Run 'just docker-up' inside whitenoise-rs first.
--transponder     Run Test 14 (push notifications via the transponder service).
--no-build        Fail instead of rebuilding wn/wnd. Useful when iterating.
-h, --help        Show help.

Environment overrides:

WN_REPO=/some/path/whitenoise-rs   # use an existing checkout

Default relays

wss://relay.damus.io
wss://nos.lol
wss://relay.primal.net

These are known to accept kind 1059 (gift wraps) and kind 30000+ (addressable events). If the sanity check fails — meaning C cannot read the KeyPackage that B just published — the harness warns you and continues. In that case re-run with --local-relays after starting the Docker stack:

cd state/whitenoise-rs
just docker-up
cd ../..
./marmot-interop.sh --local-relays

For Amethyst with --local-relays:

  • Android emulator: add ws://10.0.2.2:8080 to Settings → Relays and Settings → Key Package Relays.
  • Physical device on same Wi-Fi: add ws://<laptop-LAN-ip>:8080.

How human interaction works

When the script needs you to do something in Amethyst, it prints a yellow block like this:

---- DO THIS IN AMETHYST ----
In Amethyst:
  1. Tap + -> Create Group
  2. Name: Interop-02
  3. Add member: npub1abc...
  4. Tap Create / Send Invite
-----------------------------
[Press Enter to continue]

After you press Enter the script resumes. For UI-only verifications (e.g. "does Amethyst show reaction 🌮?"), the script asks:

? Does Amethyst show the 🌮 reaction?  [p]ass / [f]ail / [s]kip:

Pick p, f, or s.

Output

  • state/logs/run-<timestamp>.log — every step, assertion, and human prompt, with the exact wn stdout that was parsed.
  • state/results-<timestamp>.tsv — tab-separated test_id \t status \t note lines. Easy to grep.
  • state/run.env — persistent key/value state (npubs, group ids) so you can kill and resume the harness mid-run without losing context.
  • Final summary table on stdout — colored per-test status and totals.

State cleanup

The harness leaves state/ on disk so daemons and identities survive across runs. To start completely fresh:

# stops daemons if still running; removes identities, groups, logs
./marmot-interop.sh --no-build   # Ctrl-C when it waits for input, then:
rm -rf state/

Published KeyPackages on public relays will remain until they expire naturally or are deleted via wn keys delete-all --confirm. Use a throwaway identity for B/C if this matters to you.

Known gaps

  • Test 10 (concurrent commits) is inherently human-timing sensitive. It's a best-effort race; expect occasional flakes.
  • Test 14 (push) only exercises the harness side — full end-to-end verification requires a running transponder instance and platform registrations that are out of scope here.
  • The harness assumes Amethyst exposes UI affordances for add-member, remove-member, rename, promote/demote, and leave. If any of those is missing from the current build, the corresponding test will fail with a clear note rather than crash.

Files

  • marmot-interop.sh — main entry point; orchestrates preflight, daemons, identities, relays, and runs the 13 tests in sequence.
  • lib.sh — helpers (logging, prompts, polling, jq wrappers, result table).
  • state/ — runtime directory, gitignored. Contains whitenoise-rs/ source checkout, per-daemon data/log dirs, the session run.env, logs, and results TSVs.