cli: allow removing multicast roles in user subscribe#3914
cli: allow removing multicast roles in user subscribe#3914juan-malbeclabs wants to merge 5 commits into
Conversation
Change --publisher/--subscriber from bare bool flags to optional booleans so they accept an explicit value (--publisher false / --subscriber false) to remove a role. When a flag is omitted, the user's current role for the group is preserved, allowing each role to be added or removed independently. Error when neither flag is provided instead of issuing a silent no-op.
549b994 to
fc65dd3
Compare
ben-dz
left a comment
There was a problem hiding this comment.
Off-chain CLI change extending doublezero user subscribe to add/remove multicast roles via tri-state Option<bool> flags, plus a fail-closed check_accesspass Err→Ok(false) migration and connect.rs spinner output tweaks. The logic is correct, backward-compatible, and the four CLI unit tests pass. No blocking defects and no security issues (the missing-AccessPass path is fail-closed; preserve-role resolution cannot bypass onchain allowlist/payer checks). Findings are usability sharp-edges and test-quality gaps. Top items: (M1) preserving an already-held role re-asserts true and re-runs the onchain allowlist gate, so an unrelated removal can be rejected if the allowlist drifted — but this is a pre-existing processor property the PR inherits, not a regression; (M2) the no-silent-no-op guard only catches the both-omitted case. Recommend tightening tests L1/L2 (and optionally a parse test L3) in this PR and tracking M1/M2 as docs/follow-up.
- Changelog present and correct under
### Changes, referencing (#3914). Cosmetic nit: there are now two separate- CLIbullets in the same block; consider merging. - Security: no findings. check_accesspass returning Ok(false) on a missing pass (requirements.rs:84-105) is fail-closed — the sole caller connect.rs:133 gates on
if !check_accesspass(...)?and bails, so false is treated as unauthorized; epoch enforcement is unchanged; grep confirms connect.rs:133 is the only consumer. The preserve-role resolution reads client-fetched state but cannot bypass the authoritative onchain allowlist/payer checks. Logged client IP/payer are public per the project trust model. - smartcontract/cli/src/user/subscribe.rs:99-101 — Low/tidiness: the --wait path round-trips the already-resolved group_pk back through GetMulticastGroupCommand's code-resolution (
pubkey_or_code: group_pk.to_string()). Harmless and pre-existing.
| for group_pk in &group_pks { | ||
| let publisher = self | ||
| .publisher | ||
| .unwrap_or_else(|| user.publishers.contains(group_pk)); |
There was a problem hiding this comment.
Preserve-on-omit re-asserts an already-held role as true: --subscriber false on a current publisher resolves publisher=true and sends it. Both the SDK (sdk/rs/src/commands/multicastgroup/subscribe.rs:57-62) and the onchain processor gate publisher==true on the current allowlist before the idempotent add/remove check, so if the pub allowlist was narrowed since the user subscribed, an unrelated subscriber-removal is rejected with NotAllowed. Note this is NOT a regression — the old CLI's --publisher (subscriber absent) hit the same gate. Recommend documenting the coupling, or a follow-up so the processor skips the allowlist check when the role is already present. Non-blocking.
There was a problem hiding this comment.
Documented the coupling in a code comment at the role-resolution site (892baf0): preserving an already-held role re-asserts true, which re-runs the onchain allowlist gate, so an unrelated removal can be rejected with NotAllowed if the allowlist drifted. Noted it's an inherited processor property, not a regression. A processor-side skip of the allowlist check when the role is already present is left as a follow-up.
| client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; | ||
|
|
||
| // Require at least one role flag so we never issue a silent no-op. | ||
| if self.publisher.is_none() && self.subscriber.is_none() { |
There was a problem hiding this comment.
The "never a silent no-op" guard only rejects the both-omitted case. --publisher true against a user who is already a publisher still issues a transaction the processor treats as a no-op (the contains checks short-circuit any write), yet it still costs a signature/credits. Either tighten the changelog wording or add a comment acknowledging this paid-no-op gap.
There was a problem hiding this comment.
Extended the "never a silent no-op" comment to acknowledge the gap: the guard only rejects the both-omitted case, so re-asserting a role the user already holds still issues a transaction the processor treats as a no-op, costing a signature/credits. (892baf0)
| } | ||
| .execute(&ctx, &client, &mut output), | ||
| ); | ||
| assert!(res.is_err()); |
There was a problem hiding this comment.
test_cli_user_subscribe_requires_role_flag asserts only res.is_err(). Per the repo's testing guidance (assert specific errors, not .is_err()), assert the bail message contains the user-facing guidance ("Specify at least one of --publisher...") so the test can't pass on an unrelated future failure.
There was a problem hiding this comment.
Done in 892baf0 — the test now asserts the bail message contains "Specify at least one of --publisher" instead of just is_err().
| } | ||
| .execute(&ctx, &client, &mut output), | ||
| ); | ||
| assert!(res.is_ok()); |
There was a problem hiding this comment.
test_cli_user_unsubscribe_publisher_preserves_subscriber verifies the mock call args and Ok result but does not assert the new "Updated roles for ..." output line (the two earlier tests do). Add the output assertion, and consider the symmetric case (preserve publisher while removing subscriber).
There was a problem hiding this comment.
Done in 892baf0 — added the "Updated roles for ..." output assertion, and added the symmetric test test_cli_user_unsubscribe_subscriber_preserves_publisher (preserve publisher while removing subscriber).
| /// Add (`--subscriber` or `--subscriber true`) or remove (`--subscriber false`) the | ||
| /// subscriber role. When omitted, the current subscriber role is left unchanged. | ||
| #[arg(long, num_args = 0..=1, default_missing_value = "true")] | ||
| pub subscriber: Option<bool>, |
There was a problem hiding this comment.
The new clap surface (num_args = 0..=1, default_missing_value = "true") coexisting with the variadic --group (num_args = 1..) is never exercised — all tests construct the struct directly. A try_parse_from test covering bare --publisher, --publisher false, and --group g1 g2 --publisher ordering is cheap insurance against argument-ambiguity regressions.
There was a problem hiding this comment.
Added test_cli_user_subscribe_parses_role_flags in 892baf0 using a try_parse_from wrapper, covering bare --publisher, --publisher false, and --group g1 g2 --publisher ordering (both groups land in groups, publisher resolves to true).
| // Subscribe to each group | ||
| // Update roles for each group. An omitted flag preserves the user's | ||
| // current role for that group; the processor toggles add/remove based on | ||
| // the resolved boolean values. |
There was a problem hiding this comment.
Nit: comment says the processor "toggles add/remove based on the resolved boolean values," but the processor sets absolute state (idempotent add when true, idempotent remove when false), not a relative toggle. Reword to avoid implying relative state.
There was a problem hiding this comment.
Reworded in 892baf0 to say the processor sets absolute state (idempotent add when true, idempotent remove when false), not a relative toggle.
| // Subscribe to remaining groups | ||
| for group_pk in all_group_pks.iter().skip(1) { | ||
| spinner.println(format!(" Subscribing to group: {group_pk}")); | ||
| spinner.set_message(format!("Subscribing to group: {group_pk}")); |
There was a problem hiding this comment.
spinner.println(...) → spinner.set_message(...) makes per-group subscribe progress transient (overwritten by the next set_message, then cleared by finish_and_clear() at line 172), so a multi-group connect no longer prints a line per group. Consistent with the preceding commit's intent and acceptable, but confirm no operator/CI flow relied on those persistent lines in captured output.
There was a problem hiding this comment.
Confirmed — the per-group lines were diagnostic progress output, not machine-parsed by any operator/CI flow, so making them transient is fine. Leaving as-is, consistent with the preceding commit's intent.
Tighten tests (assert error message, output line, symmetric preserve case, clap parse coverage), reword the processor toggle comment to absolute state, document the allowlist re-assertion coupling and paid-no-op gap, and merge the duplicate CLI changelog bullet groups.
|
Thanks for the review. Addressed all findings in 892baf0:
|
Summary of Changes
doublezero user subscribenow updates multicast roles rather than only adding them.--publisher/--subscriberaccept an explicit value:--publisher falsedrops the role,--publisher(or--publisher true) adds it, and an omitted flag preserves the user's current role for each group.--publishernor--subscriberis given, so it never issues a silent no-op.Subscribed to <group>toUpdated roles for <group>to reflect add/remove semantics.check_accesspassreturnsOk(false)when the access pass is missing instead of bailing with a generic "not found" error, letting the caller render its own diagnostic (client IP and UserPayer).doublezero connectuses spinner messages instead of printing separate lines while subscribing to groups.Diff Breakdown
Small core-logic change to subscribe semantics, the bulk of additions are new unit tests covering role removal and the required-flag guard.
Key files (click to expand)
smartcontract/cli/src/user/subscribe.rs—--publisher/--subscriberbecomeOption<bool>withnum_args = 0..=1; resolve each role per-group (omitted = preserve current), require at least one flag, plus tests for role-removal and the required-flag guardsmartcontract/cli/src/requirements.rs—check_accesspassreturnsOk(false)on a missing access pass instead of erroringclient/doublezero/src/command/connect.rs— usespinner.set_messageinstead ofspinner.printlnduring group subscriptionTesting Verification
test_cli_user_unsubscribe_publisher_preserves_subscriber), and the command errors when neither role flag is supplied (test_cli_user_subscribe_requires_role_flag).Option<bool>flags andUpdated roles for ...output.