Skip to content

serviceability: add SubscribeFeed and UnsubscribeFeed for EdgeSeat feeds - #4113

Open
martinsander00 wants to merge 6 commits into
mainfrom
ms/4109
Open

serviceability: add SubscribeFeed and UnsubscribeFeed for EdgeSeat feeds#4113
martinsander00 wants to merge 6 commits into
mainfrom
ms/4109

Conversation

@martinsander00

@martinsander00 martinsander00 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Resolves #4109

Summary

  • New SubscribeFeed (117) and UnsubscribeFeed (118) instructions join or leave whole feeds on an EdgeSeat access pass, in one atomic transaction. Seats are charged per feed, not per group.
  • A feed is all-or-nothing: the caller names feeds, the processor derives which groups change, and a group list that does not match is rejected so a stale client cannot half-apply. Because a seat follows the feed the caller named rather than being inferred from group membership, two feeds carrying the same group stay unambiguous — leaving one keeps the shared group and releases only that feed's seat.
  • An unsubscribe must pass every feed the user holds. Without the retained feeds' group sets the processor cannot tell that a departing feed is dropping a group another held feed still covers, so it rejects rather than stranding that feed's seat.
  • UpdateMulticastGroupRoles now enforces the multicast-group allowlists for every access-pass type, EdgeSeat included, which previously bypassed both. Purchased groups go through the feed instructions and charge a seat; individually comped groups go through the allowlist and charge nothing.
  • CreateSubscribeUser skips the allowlist only for the case create_user_core's feed gate actually covers, and always checks the publisher allowlist. Two paths could previously join a group with no check at all: an EdgeSeat pass creating a non-multicast user, and an EdgeSeat multicast user taking a publisher role on a feed group it only bought receive rights to.

Not breaking: no EdgeSeat pass exists on mainnet-beta, and the EdgeSeat path through UpdateMulticastGroupRoles was already unreachable — no builder emitted the device account its coverage check required, so every such call failed with MetroMismatch.

Must land before #4110 (naked CreateUser), or naked EdgeSeat users could reach any group through an ungated UpdateMulticastGroupRoles.

Testing Verification

  • Seat accounting: a feed's whole group set joins for one seat (cap set to 1, so a per-group tick would surface as FeedSeatFull); a second feed takes its own seat; two feeds join in one transaction; leaving releases the seat.
  • test_leaving_a_feed_keeps_a_group_a_retained_feed_covers: two feeds both carry g0, the user leaves one, keeps g0 via the retained feed, and only the departing feed's seat is released.
  • test_leave_omitting_a_held_feed_rejected: a leave that omits a held feed is refused instead of stranding its seat.
  • test_seat_cap_rejects_a_second_machine fills a one-user feed with a real second user on the same dynamic pass, rather than a synthetic full seat.
  • Regression suites green for the paths whose authorization moved: create_subscribe_user_test, multicastgroup_subscribe_test, feed_metro_gate_test.

Joins or leaves every multicast group carried by one or more feeds on an
EdgeSeat access pass in a single atomic transaction, charging one seat per
feed rather than per group. Seat state is reconciled against the user's
final group membership, so adds, partial removals and full removals all
fall out of one comparison.

With feed joins handled here, UpdateMulticastGroupRoles no longer needs to
know EdgeSeat exists: the !is_edge_seat allowlist bypass is deleted and
the allowlist applies to every pass type. Purchased groups go through the
feed instruction and charge a seat; individually comped groups go through
the allowlist and charge nothing.

Authorization moves out of update_user_multicastgroup_roles into each
caller, so no call site can claim a check it did not perform. The helper
is now mechanics only and no longer takes the access pass.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an EdgeSeat-specific onchain join/leave path for multicast “feed” purchases, and tightens role updates so allowlists are uniformly enforced outside the feed-derived EdgeSeat flow.

Changes:

  • Introduces UpdateFeedSubscription (instruction variant 117) and wires it through the program entrypoint, instruction enum, and Rust instruction-builder crate.
  • Refactors multicast role updates so allowlist authorization is performed by processors (via a shared helper) rather than being implicitly bundled into update_user_multicastgroup_roles.
  • Adds a dedicated integration test suite for feed seat accounting and rejection paths; bumps program-test compute budget to the protocol max.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs Raises program-test compute unit cap to match protocol maximum for heavier transactions.
smartcontract/programs/doublezero-serviceability/tests/feed_subscription_test.rs New integration tests for UpdateFeedSubscription seat accounting and key rejection paths.
smartcontract/programs/doublezero-serviceability/src/processors/user/create_subscribe.rs Moves multicast allowlist enforcement into this caller for non-EdgeSeat passes.
smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs Introduces check_mgroup_allowlists and makes role-update helper “mechanics-only”.
smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe_feed.rs New processor implementing UpdateFeedSubscription and feed seat reconciliation.
smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/mod.rs Exposes the new subscribe_feed module.
smartcontract/programs/doublezero-serviceability/src/instructions.rs Adds instruction variant 117 to the instruction enum and decoding/display helpers.
smartcontract/programs/doublezero-serviceability/src/error.rs Adds new error variants and ProgramError mappings for the feed subscription flow.
smartcontract/programs/doublezero-serviceability/src/entrypoint.rs Routes UpdateFeedSubscription to its processor.
crates/doublezero-serviceability-instruction/src/multicastgroup.rs Adds a Rust instruction builder for UpdateFeedSubscription.
CHANGELOG.md Documents the new instruction, allowlist enforcement change, and new errors under Unreleased.

Comment thread smartcontract/programs/doublezero-serviceability/tests/feed_subscription_test.rs Outdated

@nikw9944 nikw9944 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One authorization gap to close before merge, plus two seat-accounting cases and a test-coverage gap.

The EdgeSeat allowlist skip in create_subscribe.rs keys only on pass type, while the feed gate it defers to keys on pass type and user_type == Multicast and never separates the publisher role from the subscriber role. That leaves two ungated paths: an EdgeSeat pass creating a non-Multicast user can join any Activated multicast group with no check at all, and an EdgeSeat Multicast user can take a publisher role on a feed group without being on mgroup_pub_allowlist — live publish rights on content a feed sells receive-only. The second is the same hole the PR description says it closed; it was closed in UpdateMulticastGroupRoles only, not at connect. Since #4110 is sequenced behind this PR precisely so no ungated path into a group survives, this is worth fixing here.

Separately, the seat reconcile in the new processor holds a feed seat on publisher-only membership and only reconciles the feeds passed in this call, so both a comped-publisher user and overlapping feed group sets can leave a seat silently charged on the pass until user delete. Three of the four new rejection paths have no test.

  • tests/feed_subscription_test.rs: EdgeSeatRequired (101) and EdgeSeatIsMulticastOnly (104) are introduced with no test, as are the two InvalidArgument arg-shape rejections (feed_count == 0 / no group accounts, subscribe_feed.rs:108; duplicate feed, subscribe_feed.rs:201). 101 is the primary pass-type gate on the new instruction. CLAUDE.md Testing §4 asks for all error paths; the rest of this file already does that well.

Split the combined instruction into SubscribeFeed (117) and UnsubscribeFeed
(118). Nearly every decision was mode-dependent, so the shared preamble moves
to multicastgroup/feed.rs and each processor reads straight down with no
subscriber flag. Passing retained feeds to a join is now a decode error rather
than a runtime check.

A feed is all-or-nothing: the caller names feeds and the processor derives the
groups that change, rejecting a group list that does not match. Seats follow the
named feed instead of being inferred from group membership, so a comped
publisher role no longer pins a seat, and leaving one of two feeds that carry
the same group keeps the group and releases only that feed's seat. An
unsubscribe must pass every feed the user holds, or the call is rejected rather
than stranding a seat.

create_subscribe now skips the allowlist only for the case create_user_core's
feed gate actually covers (EdgeSeat and Multicast), and always checks the
publisher allowlist. Previously an EdgeSeat pass could create a non-multicast
user that joined any activated group unchecked, or take a publisher role on a
feed group it only bought receive rights to.

Adds tests for EdgeSeatRequired, EdgeSeatIsMulticastOnly, the argument-shape
rejections, and the retained-feed cases.
@martinsander00 martinsander00 changed the title serviceability: add UpdateFeedSubscription for EdgeSeat feed joins serviceability: add SubscribeFeed and UnsubscribeFeed for EdgeSeat feeds Jul 29, 2026
assert_custom_error searched the error's debug text, which would also match a
failure from a different instruction in the same transaction and breaks if the
formatting changes. Matches TransactionError::InstructionError directly, as
accesspass_dzf_locked.rs already does.

Also corrects the doc on update_user_multicastgroup_roles: publisher and
subscriber are desired states, not toggle signals.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

smartcontract/programs/doublezero-serviceability/tests/feed_subscription_test.rs:285

  • This comment still references UpdateFeedSubscription; the behavior under test is now exercised via SubscribeFeed/UnsubscribeFeed.
/// Bring the Multicast user into existence on `feed` joined to `group`. `UpdateFeedSubscription`
/// operates on an existing user, and until `CreateUser` is made naked (#4110) this is the only way
/// to create one under an EdgeSeat pass.

smartcontract/programs/doublezero-serviceability/tests/feed_subscription_test.rs:752

  • The comment refers to UpdateFeedSubscription, but the purchased-group path is now SubscribeFeed/UnsubscribeFeed. Keeping the name consistent helps future readers connect the test to the correct instruction.
// The hole this PR closes: an EdgeSeat holder may no longer reach a group through
// UpdateMulticastGroupRoles just because a feed carries it. That path is now allowlist-only, so a
// purchased group must go through UpdateFeedSubscription and charge its seat. NotAllowed (8).

smartcontract/programs/doublezero-serviceability/tests/feed_subscription_test.rs:1

  • The module-level doc comment names UpdateFeedSubscription variant 117, but the instructions added in this PR are SubscribeFeed (117) and UnsubscribeFeed (118).

This issue also appears in the following locations of the same file:

  • line 283
  • line 750
//! `UpdateFeedSubscription` (variant 117) — joining and leaving whole feeds on an EdgeSeat pass.

smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs:640

  • The comment still refers to the old instruction name UpdateFeedSubscription, but the program now exposes SubscribeFeed/UnsubscribeFeed. Updating this keeps test comments aligned with the actual instruction names.
    // SetGlobalConfig creates multiple ResourceExtension accounts; raise the budget so this
    // doesn't flake under load when many test processes run concurrently. Set to the protocol max
    // (mirrors MAX_COMPUTE_UNIT_LIMIT) so instructions that write many accounts in one transaction
    // — e.g. UpdateFeedSubscription across several feeds and groups — also fit.

@nikw9944 nikw9944 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five findings from the previous round are verified fixed. The redesign that fixed them — splitting into SubscribeFeed (117) and UnsubscribeFeed (118), with seats following the named feed rather than inferred group membership — introduces a seat-metering bypass that must be closed before merge.

UnsubscribeFeed lets a pass holder keep every group in a feed while releasing its seat. targets and retained are loaded by two independent load_feeds calls with no disjointness check, and a retained feed is never required to be one the user holds. Passing the same feed as both target and retained makes every one of its groups "still covered", so expected is empty and no group is unsubscribed — but the seat loop still releases the seat and clears feed_pks. The controller renders multicast subscribers straight from user.Subscribers and never consults feed_pks, so the result is live multicast receive with the paid-capacity metering fully bypassed, reachable by the pass's own user_payer.

Two further limits in the new design: the mandatory retained-feed coverage makes leaving permanently impossible once an admin drops a held feed from the pass, and SubscribeFeed's exactly-all-or-nothing group list means a feed created at MAX_FEED_GROUPS can never be joined at all.

UnsubscribeFeed took targets and retained from two independent load_feeds calls
and never related them, so a caller could release a seat while keeping the
groups. Naming the departing feed as retained made every one of its groups look
covered, leaving nothing to unsubscribe while the seat loop still ran; naming a
feed the user never held but which overlaps the target kept the shared groups the
same way. Neither needed a duplicate account meta, and the pass's own user_payer
could do it. The controller renders MulticastSubscribers from user.subscribers
and never reads feed_pks, so this was live receive with the metering bypassed.
Retained feeds must now be disjoint from targets and held by the user.

MAX_FEED_GROUPS drops from 64 to 20. SubscribeFeed is all-or-nothing, so joining
passes every group a feed carries; a legacy transaction fits 34 accounts and the
instruction spends 9, leaving 25. A 64-group feed could never be joined.

@nikw9944 nikw9944 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The seat-metering bypass from the last round is fixed in code — unsubscribe_feed.rs:85-94 now rejects a feed in both lists and any retained feed not in user.feed_pks, with tests covering both variants and asserting the seat and subscriptions survive the rejected call.

Two Mediums remain. The dropped-feed lockout is unchanged and deferred to #4114, but the deferral answers a different fix: exempting a held feed that is absent from accesspass.feed_seats() is local to this processor and needs no cascade, and without it a routine SetAccessPassFeeds leaves a user's other seats permanently unreleasable. And the transaction-capacity bound added to MAX_FEED_GROUPS was not applied to the sibling cap — UnsubscribeFeed must name every held feed, so the 64-feed feed_pks cap permits a held-feed count no leave transaction can carry, reaching the same un-leavable end state. The new constant also has no boundary test.

}
}

for held in &ctx.user.feed_pks {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unchanged, and deferred to #4114 — but the deferral answers a different fix than the one needed.

SetAccessPassFeeds replaces the whole seat list and never refuses to drop a feed with current_users > 0 (set_feeds.rs:136-141 only carries counts forward for feeds still listed). Afterwards this loop requires every entry in user.feed_pks to be passed as a target or retained, but load_feeds rejects the now-unprovisioned feed with FeedNotOnAccessPass (93) at lines 72-83 — before the new retained-validation block. The user can then never release the seats on their other feeds either; only DeleteUser clears them via release_feed_seats.

The reply describes cascading cleanup across every affected User and MulticastGroup account. The fix asked for here is local to this processor and touches no other account: a held feed absent from accesspass.feed_seats() has no seat left to strand, so exempt it from the coverage requirement and prune it from feed_pks in the same pass. If the admin-side orchestration in #4114 is the preferred long-term shape, this processor still needs the exemption so an operator mistake is not a permanent lockout.

if ctx.user.feed_pks.contains(feed_key) {
continue;
}
if ctx.user.feed_pks.len() >= MAX_ACCESS_PASS_FEEDS {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lowering MAX_FEED_GROUPS to 20 does fix the join path — a 1-feed/20-group SubscribeFeed is 29 unique accounts, ≈1063 bytes, inside both the 64-account lock limit and the 1232-byte legacy limit. Two gaps remain:

The sibling cap is unbounded. UnsubscribeFeed must name every held feed, so its account count is 8 + feeds_held + groups_departing. MAX_ACCESS_PASS_FEEDS = 64 — borrowed here as the feed_pks cap — permits 64 held feeds. Past roughly 6 held feeds at max group size a legacy transaction no longer fits (feeds_held + groups ≤ 26), and past ~36 no transaction fits at all (≤ 56 under the lock limit). That is the same un-leavable end state as the dropped-feed case, reached through the cap this commit did not revisit. Bound this cap by what one leave transaction can name rather than reusing MAX_ACCESS_PASS_FEEDS.

No boundary test. The new MAX_FEED_GROUPS = 20 is justified by transaction capacity but nothing exercises it; the largest fixture feed carries 3 groups. A test that joins a 20-group feed in one transaction pins the constant to its stated reason and stops it drifting back up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

serviceability: add UpdateFeedSubscription instruction for EdgeSeat feed joins

3 participants