Interop Test 06 (wn removes a member from a group Amethyst is in) was
failing with:
GroupEventHandler.add: ERROR Failed to apply commit:
UpdatePath node count (1) doesn't match direct path length (2)
Repro scenario: 3-member group [B=leaf 0, C=leaf 1, A=leaf 2]; B (admin,
committer) removes C. After the Remove proposal applies, leaf 1 is
blank but leaf 2 (A) is still occupied — so leaf_count stays at 3 per
RFC 9420 §7.8 and the sender's direct path has length 2 ([node 1,
root]).
Per RFC 9420 §4.1.2 and §7.9 the **filtered direct path** drops any
parent node whose child on the copath has an empty resolution:
encrypting to such a parent is equivalent to encrypting to its only
non-blank child, which is already on the direct path. In our scenario
the parent at level 1 (parent of leaves 0, 1) has a blank leaf 1 on
the copath — empty resolution — so it's filtered out. Filtered
direct path length is 1, and that's what openmls / whitenoise-rs emit
in `UpdatePath.nodes<V>`.
Quartz was always using the unfiltered direct path on both sides
(send + receive + parent-hash chain + path-secret decrypt). That
worked for quartz↔quartz (both sides agreed), but broke the moment a
spec-correct sender (wn) sent a filtered UpdatePath into a quartz
receiver. Parent-hash chain is further affected because §7.9.2 walks
the filtered path — even if the size check passed, the hashes would
not match.
Fix it throughout:
- RatchetTree.filteredDirectPath: new helper returning parallel
(filteredDirectPath, filteredCopath) with the empty-resolution
entries dropped. Uses the existing `resolution()` helper, which
already handles unmerged_leaves per §4.1.2.
- RatchetTree.applyUpdatePath: size-check + target the FILTERED path.
- MlsGroup.commit (sender): emit one staged path node per filtered
level; encrypt path secrets using the filtered copath; patch parent
hashes into the filtered positions only.
- MlsGroup.processCommitInner (receiver): the UpdatePath node index
aligns to the filtered direct path, so the common-ancestor lookup
must find the filtered index to pick the right ciphertext + copath
resolution. Use the unfiltered index only for counting KDF steps
to the root (path_secret chain advances one step per unfiltered
level regardless of filtering — the chain is continuous; filtering
only decides which levels emit a UpdatePathNode, not how many KDF
steps separate them).
- MlsGroup.computeSenderParentHashes: walk the filtered direct path.
Map each filtered node back to its unfiltered level so the
preUpdateSiblingHashes lookup (indexed by unfiltered level) still
resolves. This makes the parent_hash chain agree with §7.9.2 and
with what openmls computes on the other side.
- MlsGroup.verifyParentHash: short-circuit on empty filtered path
rather than empty unfiltered path.
New regression test: testRemoveMiddleLeaf_ReceiverAcceptsCommit
reproduces the 3-member remove-middle scenario end-to-end within
quartz. It passed before this patch (because both sides were
unfiltered and agreed with each other), and it passes after this
patch (because both sides are now filtered and still agree) — the
compatibility win is that quartz now also agrees with
openmls/wn-produced commits of the same shape.
Also fix an unrelated script bug in marmot-interop.sh: the
discover_a_relays SQL probe was falsely reporting "wn has NO cached
relay entries for A" even when welcome delivery was working, because
the query assumed users.pubkey stored as BLOB but wn stores it as
TEXT (hex) in the current schema. Accept either encoding.
Marmot Interop Test Harness
Two flavours, same scenarios:
marmot-interop.sh— interactive. Drives B/C viawnand 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 theamyCLI (./gradlew :cli:installDist) and B/C viawn. 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:
- Verify
jq,git,cargoetc. are present. - Clone
whitenoise-rsintostate/whitenoise-rs/and buildwn/wnd(release,--features cli). First build takes ~5 minutes; subsequent runs reuse the binaries. - Launch two
wnddaemons (one for Identity B, one for Identity C). - Create Nostr identities for B and C, persist their npubs in
state/run.env. - Ask you to paste your Amethyst account npub (Identity A). This is cached for subsequent runs.
- Add the default public relays to both daemons and run a sanity check (publish a KP from B, fetch it from C).
- Print an Amethyst setup checklist — add the same relays to Amethyst, publish a KP, verify you are logged in with A.
- Run all 13 tests sequentially. Each test either:
- runs
wncommands 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 viawn.
- runs
- 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:8080to 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 exactwnstdout that was parsed.state/results-<timestamp>.tsv— tab-separatedtest_id \t status \t notelines. 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
transponderinstance 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. Containswhitenoise-rs/source checkout, per-daemon data/log dirs, the sessionrun.env, logs, and results TSVs.