Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file.

### Changes

- Serviceability
- 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)

## [v0.32.0](https://github.com/malbeclabs/doublezero/compare/client/v0.31.0...client/v0.32.0) - 2026-07-29

### Breaking
Expand Down
87 changes: 87 additions & 0 deletions crates/doublezero-serviceability-instruction/src/multicastgroup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ use doublezero_serviceability::{
delete::MulticastGroupDeleteArgs,
reactivate::MulticastGroupReactivateArgs,
subscribe::UpdateMulticastGroupRolesArgs,
subscribe_feed::SubscribeFeedArgs,
suspend::MulticastGroupSuspendArgs,
unsubscribe_feed::UnsubscribeFeedArgs,
update::MulticastGroupUpdateArgs,
},
resource::ResourceType,
Expand Down Expand Up @@ -198,6 +200,91 @@ pub fn update_multicast_group_roles(
)
}

/// `SubscribeFeed` (variant 117) — join whole feeds on an EdgeSeat access pass.
///
/// Accounts: `[accesspass, user, globalstate, device, feeds.., groups..]`.
///
/// `groups` must be exactly the groups this call adds; the processor derives that set from the feeds
/// and rejects a mismatch, so a stale client cannot half-apply a change. `feed_count` is derived from
/// `feeds.len()` rather than trusted from the caller.
pub fn subscribe_feed(
program_id: &Pubkey,
payer: &Pubkey,
accesspass: &Pubkey,
user: &Pubkey,
device: &Pubkey,
feeds: &[Pubkey],
groups: &[Pubkey],
) -> Instruction {
let (globalstate, _) = get_globalstate_pda(program_id);
let mut accounts = vec![
AccountMeta::new(*accesspass, false),
AccountMeta::new(*user, false),
AccountMeta::new(globalstate, false),
AccountMeta::new_readonly(*device, false),
];
accounts.extend(
feeds
.iter()
.map(|feed| AccountMeta::new_readonly(*feed, false)),
);
accounts.extend(groups.iter().map(|group| AccountMeta::new(*group, false)));

common::build_with_permission(
program_id,
DoubleZeroInstruction::SubscribeFeed(SubscribeFeedArgs {
feed_count: feeds.len() as u8,
}),
accounts,
payer,
)
}

/// `UnsubscribeFeed` (variant 118) — leave whole feeds on an EdgeSeat access pass.
///
/// Accounts: `[accesspass, user, globalstate, device, targets.., retained.., groups..]`.
///
/// `retained` must be every feed the user keeps: two feeds on one pass can carry the same group, and
/// without the retained group sets the processor would drop a group another held feed still covers and
/// strand that feed's seat. Together `targets` and `retained` must cover every held feed still
/// provisioned on the pass; a held feed the pass dropped is pruned by the processor instead.
#[allow(clippy::too_many_arguments)]
pub fn unsubscribe_feed(
program_id: &Pubkey,
payer: &Pubkey,
accesspass: &Pubkey,
user: &Pubkey,
device: &Pubkey,
targets: &[Pubkey],
retained: &[Pubkey],
groups: &[Pubkey],
) -> Instruction {
let (globalstate, _) = get_globalstate_pda(program_id);
let mut accounts = vec![
AccountMeta::new(*accesspass, false),
AccountMeta::new(*user, false),
AccountMeta::new(globalstate, false),
AccountMeta::new_readonly(*device, false),
];
accounts.extend(
targets
.iter()
.chain(retained)
.map(|feed| AccountMeta::new_readonly(*feed, false)),
);
accounts.extend(groups.iter().map(|group| AccountMeta::new(*group, false)));

common::build_with_permission(
program_id,
DoubleZeroInstruction::UnsubscribeFeed(UnsubscribeFeedArgs {
feed_count: targets.len() as u8,
retained_feed_count: retained.len() as u8,
}),
accounts,
payer,
)
}

/// `AddMulticastGroupPubAllowlist` (variant 54).
/// Accounts: `[mgroup, accesspass, globalstate, user_payer]`.
pub fn add_multicast_group_pub_allowlist(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ use crate::{
delete::process_delete_multicastgroup,
reactivate::process_reactivate_multicastgroup,
subscribe::process_update_multicastgroup_roles,
subscribe_feed::process_subscribe_feed,
suspend::process_suspend_multicastgroup,
unsubscribe_feed::process_unsubscribe_feed,
update::process_update_multicastgroup,
},
permission::{
Expand Down Expand Up @@ -427,6 +429,12 @@ pub fn process_instruction(
DoubleZeroInstruction::SetAccessPassFlags(value) => {
process_set_access_pass_flags(program_id, accounts, &value)?
}
DoubleZeroInstruction::SubscribeFeed(value) => {
process_subscribe_feed(program_id, accounts, &value)?
}
DoubleZeroInstruction::UnsubscribeFeed(value) => {
process_unsubscribe_feed(program_id, accounts, &value)?
}
};
Ok(())
}
16 changes: 16 additions & 0 deletions smartcontract/programs/doublezero-serviceability/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,14 @@ pub enum DoubleZeroError {
"Feed billing window is invalid (window_end must be in the future and <= terminates_at)"
)]
FeedInvalidBillingWindow, // variant 100
#[error("Feed subscriptions require an EdgeSeat access pass")]
EdgeSeatRequired, // variant 101
#[error("The passed Device account is not the user's device")]
UserDeviceMismatch, // variant 102
#[error("User would hold more feeds than an access pass may carry")]
UserFeedLimitExceeded, // variant 103
#[error("An EdgeSeat feed seat is only held by a Multicast user")]
EdgeSeatIsMulticastOnly, // variant 104
}

impl From<DoubleZeroError> for ProgramError {
Expand Down Expand Up @@ -314,6 +322,10 @@ impl From<DoubleZeroError> for ProgramError {
DoubleZeroError::FeedMaxFutureUsersBelowMaxUsers => ProgramError::Custom(98),
DoubleZeroError::FeedInvalidAnniversaryDay => ProgramError::Custom(99),
DoubleZeroError::FeedInvalidBillingWindow => ProgramError::Custom(100),
DoubleZeroError::EdgeSeatRequired => ProgramError::Custom(101),
DoubleZeroError::UserDeviceMismatch => ProgramError::Custom(102),
DoubleZeroError::UserFeedLimitExceeded => ProgramError::Custom(103),
DoubleZeroError::EdgeSeatIsMulticastOnly => ProgramError::Custom(104),
}
}
}
Expand Down Expand Up @@ -421,6 +433,10 @@ impl From<u32> for DoubleZeroError {
98 => DoubleZeroError::FeedMaxFutureUsersBelowMaxUsers,
99 => DoubleZeroError::FeedInvalidAnniversaryDay,
100 => DoubleZeroError::FeedInvalidBillingWindow,
101 => DoubleZeroError::EdgeSeatRequired,
102 => DoubleZeroError::UserDeviceMismatch,
103 => DoubleZeroError::UserFeedLimitExceeded,
104 => DoubleZeroError::EdgeSeatIsMulticastOnly,
_ => DoubleZeroError::Custom(e),
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ use crate::processors::{
delete::MulticastGroupDeleteArgs,
reactivate::MulticastGroupReactivateArgs,
subscribe::UpdateMulticastGroupRolesArgs,
subscribe_feed::SubscribeFeedArgs,
suspend::MulticastGroupSuspendArgs,
unsubscribe_feed::UnsubscribeFeedArgs,
update::MulticastGroupUpdateArgs,
},
permission::{
Expand Down Expand Up @@ -253,6 +255,9 @@ pub enum DoubleZeroInstruction {
DeleteFeed(FeedDeleteArgs), // variant 114
SetAccessPassFeeds(SetAccessPassFeedsArgs), // variant 115
SetAccessPassFlags(SetAccessPassFlagsArgs), // variant 116

SubscribeFeed(SubscribeFeedArgs), // variant 117
UnsubscribeFeed(UnsubscribeFeedArgs), // variant 118
}

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

117 => Ok(Self::SubscribeFeed(SubscribeFeedArgs::try_from(rest).unwrap())),
118 => Ok(Self::UnsubscribeFeed(UnsubscribeFeedArgs::try_from(rest).unwrap())),

_ => Err(ProgramError::InvalidInstructionData),
}
}
Expand Down Expand Up @@ -550,6 +558,8 @@ impl DoubleZeroInstruction {
Self::DeleteFeed(_) => "DeleteFeed".to_string(), // variant 114
Self::SetAccessPassFeeds(_) => "SetAccessPassFeeds".to_string(), // variant 115
Self::SetAccessPassFlags(_) => "SetAccessPassFlags".to_string(), // variant 116
Self::SubscribeFeed(_) => "SubscribeFeed".to_string(), // variant 117
Self::UnsubscribeFeed(_) => "UnsubscribeFeed".to_string(), // variant 118
}
}

Expand Down Expand Up @@ -692,6 +702,8 @@ impl DoubleZeroInstruction {
Self::DeleteFeed(args) => format!("{args:?}"), // variant 114
Self::SetAccessPassFeeds(args) => format!("{args:?}"), // variant 115
Self::SetAccessPassFlags(args) => format!("{args:?}"), // variant 116
Self::SubscribeFeed(args) => format!("{args:?}"), // variant 117
Self::UnsubscribeFeed(args) => format!("{args:?}"), // variant 118
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ use solana_program::{

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

#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Debug, Clone, Default)]
pub struct FeedCreateArgs {
Expand Down
Loading
Loading