Skip to content

Commit 72c806d

Browse files
serviceability: add SubscribeFeed and UnsubscribeFeed for EdgeSeat feeds (#4113)
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`.
1 parent b7c44cc commit 72c806d

14 files changed

Lines changed: 1815 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file.
88

99
### Changes
1010

11+
- Serviceability
12+
- New `SubscribeFeed` and `UnsubscribeFeed` instructions join or leave whole feeds on an EdgeSeat access pass in a single atomic transaction, charging one seat per feed rather than per group. A feed is all-or-nothing: the caller names feeds, the processor derives which groups change and rejects a group list that does not match, so two feeds carrying the same group stay unambiguous. `UpdateMulticastGroupRoles` now enforces the multicast-group allowlists for every access-pass type, EdgeSeat included, so purchased groups go through the feed instructions and individually comped groups through the allowlist. `CreateSubscribeUser` skips the allowlist only for the case its feed gate actually covers, closing two paths that could join a group with no check. `MAX_FEED_GROUPS` drops from 64 to 20, bounded by what one transaction can carry: because joining passes every group a feed holds, a larger feed could never be joined. No feed onchain is affected. For the same reason a user may hold at most 6 feeds, and a held feed the pass no longer carries is pruned on leave instead of blocking it. New errors: `EdgeSeatRequired` (101), `UserDeviceMismatch` (102), `UserFeedLimitExceeded` (103), `EdgeSeatIsMulticastOnly` (104). (#4109)
13+
1114
## [v0.32.0](https://github.com/malbeclabs/doublezero/compare/client/v0.31.0...client/v0.32.0) - 2026-07-29
1215

1316
### Breaking

crates/doublezero-serviceability-instruction/src/multicastgroup.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ use doublezero_serviceability::{
2121
delete::MulticastGroupDeleteArgs,
2222
reactivate::MulticastGroupReactivateArgs,
2323
subscribe::UpdateMulticastGroupRolesArgs,
24+
subscribe_feed::SubscribeFeedArgs,
2425
suspend::MulticastGroupSuspendArgs,
26+
unsubscribe_feed::UnsubscribeFeedArgs,
2527
update::MulticastGroupUpdateArgs,
2628
},
2729
resource::ResourceType,
@@ -198,6 +200,91 @@ pub fn update_multicast_group_roles(
198200
)
199201
}
200202

203+
/// `SubscribeFeed` (variant 117) — join whole feeds on an EdgeSeat access pass.
204+
///
205+
/// Accounts: `[accesspass, user, globalstate, device, feeds.., groups..]`.
206+
///
207+
/// `groups` must be exactly the groups this call adds; the processor derives that set from the feeds
208+
/// and rejects a mismatch, so a stale client cannot half-apply a change. `feed_count` is derived from
209+
/// `feeds.len()` rather than trusted from the caller.
210+
pub fn subscribe_feed(
211+
program_id: &Pubkey,
212+
payer: &Pubkey,
213+
accesspass: &Pubkey,
214+
user: &Pubkey,
215+
device: &Pubkey,
216+
feeds: &[Pubkey],
217+
groups: &[Pubkey],
218+
) -> Instruction {
219+
let (globalstate, _) = get_globalstate_pda(program_id);
220+
let mut accounts = vec![
221+
AccountMeta::new(*accesspass, false),
222+
AccountMeta::new(*user, false),
223+
AccountMeta::new(globalstate, false),
224+
AccountMeta::new_readonly(*device, false),
225+
];
226+
accounts.extend(
227+
feeds
228+
.iter()
229+
.map(|feed| AccountMeta::new_readonly(*feed, false)),
230+
);
231+
accounts.extend(groups.iter().map(|group| AccountMeta::new(*group, false)));
232+
233+
common::build_with_permission(
234+
program_id,
235+
DoubleZeroInstruction::SubscribeFeed(SubscribeFeedArgs {
236+
feed_count: feeds.len() as u8,
237+
}),
238+
accounts,
239+
payer,
240+
)
241+
}
242+
243+
/// `UnsubscribeFeed` (variant 118) — leave whole feeds on an EdgeSeat access pass.
244+
///
245+
/// Accounts: `[accesspass, user, globalstate, device, targets.., retained.., groups..]`.
246+
///
247+
/// `retained` must be every feed the user keeps: two feeds on one pass can carry the same group, and
248+
/// without the retained group sets the processor would drop a group another held feed still covers and
249+
/// strand that feed's seat. Together `targets` and `retained` must cover every held feed still
250+
/// provisioned on the pass; a held feed the pass dropped is pruned by the processor instead.
251+
#[allow(clippy::too_many_arguments)]
252+
pub fn unsubscribe_feed(
253+
program_id: &Pubkey,
254+
payer: &Pubkey,
255+
accesspass: &Pubkey,
256+
user: &Pubkey,
257+
device: &Pubkey,
258+
targets: &[Pubkey],
259+
retained: &[Pubkey],
260+
groups: &[Pubkey],
261+
) -> Instruction {
262+
let (globalstate, _) = get_globalstate_pda(program_id);
263+
let mut accounts = vec![
264+
AccountMeta::new(*accesspass, false),
265+
AccountMeta::new(*user, false),
266+
AccountMeta::new(globalstate, false),
267+
AccountMeta::new_readonly(*device, false),
268+
];
269+
accounts.extend(
270+
targets
271+
.iter()
272+
.chain(retained)
273+
.map(|feed| AccountMeta::new_readonly(*feed, false)),
274+
);
275+
accounts.extend(groups.iter().map(|group| AccountMeta::new(*group, false)));
276+
277+
common::build_with_permission(
278+
program_id,
279+
DoubleZeroInstruction::UnsubscribeFeed(UnsubscribeFeedArgs {
280+
feed_count: targets.len() as u8,
281+
retained_feed_count: retained.len() as u8,
282+
}),
283+
accounts,
284+
payer,
285+
)
286+
}
287+
201288
/// `AddMulticastGroupPubAllowlist` (variant 54).
202289
/// Accounts: `[mgroup, accesspass, globalstate, user_payer]`.
203290
pub fn add_multicast_group_pub_allowlist(

smartcontract/programs/doublezero-serviceability/src/entrypoint.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ use crate::{
7272
delete::process_delete_multicastgroup,
7373
reactivate::process_reactivate_multicastgroup,
7474
subscribe::process_update_multicastgroup_roles,
75+
subscribe_feed::process_subscribe_feed,
7576
suspend::process_suspend_multicastgroup,
77+
unsubscribe_feed::process_unsubscribe_feed,
7678
update::process_update_multicastgroup,
7779
},
7880
permission::{
@@ -427,6 +429,12 @@ pub fn process_instruction(
427429
DoubleZeroInstruction::SetAccessPassFlags(value) => {
428430
process_set_access_pass_flags(program_id, accounts, &value)?
429431
}
432+
DoubleZeroInstruction::SubscribeFeed(value) => {
433+
process_subscribe_feed(program_id, accounts, &value)?
434+
}
435+
DoubleZeroInstruction::UnsubscribeFeed(value) => {
436+
process_unsubscribe_feed(program_id, accounts, &value)?
437+
}
430438
};
431439
Ok(())
432440
}

smartcontract/programs/doublezero-serviceability/src/error.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,14 @@ pub enum DoubleZeroError {
208208
"Feed billing window is invalid (window_end must be in the future and <= terminates_at)"
209209
)]
210210
FeedInvalidBillingWindow, // variant 100
211+
#[error("Feed subscriptions require an EdgeSeat access pass")]
212+
EdgeSeatRequired, // variant 101
213+
#[error("The passed Device account is not the user's device")]
214+
UserDeviceMismatch, // variant 102
215+
#[error("User would hold more feeds than an access pass may carry")]
216+
UserFeedLimitExceeded, // variant 103
217+
#[error("An EdgeSeat feed seat is only held by a Multicast user")]
218+
EdgeSeatIsMulticastOnly, // variant 104
211219
}
212220

213221
impl From<DoubleZeroError> for ProgramError {
@@ -314,6 +322,10 @@ impl From<DoubleZeroError> for ProgramError {
314322
DoubleZeroError::FeedMaxFutureUsersBelowMaxUsers => ProgramError::Custom(98),
315323
DoubleZeroError::FeedInvalidAnniversaryDay => ProgramError::Custom(99),
316324
DoubleZeroError::FeedInvalidBillingWindow => ProgramError::Custom(100),
325+
DoubleZeroError::EdgeSeatRequired => ProgramError::Custom(101),
326+
DoubleZeroError::UserDeviceMismatch => ProgramError::Custom(102),
327+
DoubleZeroError::UserFeedLimitExceeded => ProgramError::Custom(103),
328+
DoubleZeroError::EdgeSeatIsMulticastOnly => ProgramError::Custom(104),
317329
}
318330
}
319331
}
@@ -421,6 +433,10 @@ impl From<u32> for DoubleZeroError {
421433
98 => DoubleZeroError::FeedMaxFutureUsersBelowMaxUsers,
422434
99 => DoubleZeroError::FeedInvalidAnniversaryDay,
423435
100 => DoubleZeroError::FeedInvalidBillingWindow,
436+
101 => DoubleZeroError::EdgeSeatRequired,
437+
102 => DoubleZeroError::UserDeviceMismatch,
438+
103 => DoubleZeroError::UserFeedLimitExceeded,
439+
104 => DoubleZeroError::EdgeSeatIsMulticastOnly,
424440
_ => DoubleZeroError::Custom(e),
425441
}
426442
}

smartcontract/programs/doublezero-serviceability/src/instructions.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ use crate::processors::{
5858
delete::MulticastGroupDeleteArgs,
5959
reactivate::MulticastGroupReactivateArgs,
6060
subscribe::UpdateMulticastGroupRolesArgs,
61+
subscribe_feed::SubscribeFeedArgs,
6162
suspend::MulticastGroupSuspendArgs,
63+
unsubscribe_feed::UnsubscribeFeedArgs,
6264
update::MulticastGroupUpdateArgs,
6365
},
6466
permission::{
@@ -253,6 +255,9 @@ pub enum DoubleZeroInstruction {
253255
DeleteFeed(FeedDeleteArgs), // variant 114
254256
SetAccessPassFeeds(SetAccessPassFeedsArgs), // variant 115
255257
SetAccessPassFlags(SetAccessPassFlagsArgs), // variant 116
258+
259+
SubscribeFeed(SubscribeFeedArgs), // variant 117
260+
UnsubscribeFeed(UnsubscribeFeedArgs), // variant 118
256261
}
257262

258263
impl DoubleZeroInstruction {
@@ -401,6 +406,9 @@ impl DoubleZeroInstruction {
401406
115 => Ok(Self::SetAccessPassFeeds(SetAccessPassFeedsArgs::try_from(rest).unwrap())),
402407
116 => Ok(Self::SetAccessPassFlags(SetAccessPassFlagsArgs::try_from(rest).unwrap())),
403408

409+
117 => Ok(Self::SubscribeFeed(SubscribeFeedArgs::try_from(rest).unwrap())),
410+
118 => Ok(Self::UnsubscribeFeed(UnsubscribeFeedArgs::try_from(rest).unwrap())),
411+
404412
_ => Err(ProgramError::InvalidInstructionData),
405413
}
406414
}
@@ -550,6 +558,8 @@ impl DoubleZeroInstruction {
550558
Self::DeleteFeed(_) => "DeleteFeed".to_string(), // variant 114
551559
Self::SetAccessPassFeeds(_) => "SetAccessPassFeeds".to_string(), // variant 115
552560
Self::SetAccessPassFlags(_) => "SetAccessPassFlags".to_string(), // variant 116
561+
Self::SubscribeFeed(_) => "SubscribeFeed".to_string(), // variant 117
562+
Self::UnsubscribeFeed(_) => "UnsubscribeFeed".to_string(), // variant 118
553563
}
554564
}
555565

@@ -692,6 +702,8 @@ impl DoubleZeroInstruction {
692702
Self::DeleteFeed(args) => format!("{args:?}"), // variant 114
693703
Self::SetAccessPassFeeds(args) => format!("{args:?}"), // variant 115
694704
Self::SetAccessPassFlags(args) => format!("{args:?}"), // variant 116
705+
Self::SubscribeFeed(args) => format!("{args:?}"), // variant 117
706+
Self::UnsubscribeFeed(args) => format!("{args:?}"), // variant 118
695707
}
696708
}
697709
}

smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ use solana_program::{
2222

2323
/// Maximum `name` length, matching the Exchange/Location `name` cap.
2424
pub const MAX_FEED_NAME_LEN: usize = 64;
25-
/// Maximum number of multicast groups in a feed.
26-
pub const MAX_FEED_GROUPS: usize = 64;
25+
/// Maximum number of multicast groups in a feed. A feed's whole group set joins in one
26+
/// `SubscribeFeed` transaction, so this is bounded by transaction capacity.
27+
pub const MAX_FEED_GROUPS: usize = 20;
2728

2829
#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Debug, Clone, Default)]
2930
pub struct FeedCreateArgs {

0 commit comments

Comments
 (0)