Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ All notable changes to this project will be documented in this file.
- Serviceability
- `Feed` account: a catalog mapping `metro(exchange) → group-set`, managed by a catalog admin (`FEED_AUTHORITY` Permission or `FOUNDATION`) via `CreateFeed`/`UpdateFeed`/`DeleteFeed`. A feed with no metros imposes no restriction. (#1700)
- `SetAccessPassFeeds` provisions feed_keys (SKU seats) onto an EdgeSeat pass; the oracle calls it via its `ACCESS_PASS_ADMIN` Permission. (#1700)
- EdgeSeat multicast connect is metro-gated: a device whose exchange is not covered by any of the pass's feeds is rejected with `MetroMismatch`, and the matching feed's per-feed cap is enforced. (#1700)

### Changes

Expand Down
5 changes: 4 additions & 1 deletion smartcontract/cli/src/tenant/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ impl DeleteTenantCliCommand {

for user_pk in &tenant_users {
spinner.set_message(format!("Deleting user {user_pk}"));
client.delete_user(DeleteUserCommand { pubkey: *user_pk })?;
client.delete_user(DeleteUserCommand {
pubkey: *user_pk,
feed_pk: None,
})?;
spinner.inc(1);
}

Expand Down
2 changes: 2 additions & 0 deletions smartcontract/cli/src/user/create_subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ impl CreateSubscribeUserCliCommand {
.ok_or(eyre::eyre!("Subscriber is required if publisher is not"))?,
tunnel_endpoint: Ipv4Addr::UNSPECIFIED,
owner: owner_pk,
feed_pk: None,
})?;
writeln!(out, "Signature: {signature}",)?;

Expand Down Expand Up @@ -234,6 +235,7 @@ mod tests {
mgroup_pk: mgroup_pubkey,
tunnel_endpoint: Ipv4Addr::UNSPECIFIED,
owner: None,
feed_pk: None,
}))
.times(1)
.returning(move |_| Ok((signature, pda_pubkey)));
Expand Down
10 changes: 8 additions & 2 deletions smartcontract/cli/src/user/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ impl DeleteUserCliCommand {
client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?;

let pubkey = Pubkey::from_str(&self.pubkey)?;
let signature = client.delete_user(DeleteUserCommand { pubkey })?;
let signature = client.delete_user(DeleteUserCommand {
pubkey,
feed_pk: None,
})?;
writeln!(out, "Signature: {signature}",)?;

Ok(())
Expand Down Expand Up @@ -100,7 +103,10 @@ mod tests {

client
.expect_delete_user()
.with(predicate::eq(DeleteUserCommand { pubkey: pda_pubkey }))
.with(predicate::eq(DeleteUserCommand {
pubkey: pda_pubkey,
feed_pk: None,
}))
.returning(move |_| Ok(signature));

/*****************************************************************************************************/
Expand Down
5 changes: 4 additions & 1 deletion smartcontract/cli/src/user/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,10 @@ mod tests {

client
.expect_delete_user()
.with(predicate::eq(DeleteUserCommand { pubkey: pda_pubkey }))
.with(predicate::eq(DeleteUserCommand {
pubkey: pda_pubkey,
feed_pk: None,
}))
.returning(move |_| Ok(signature));

// Expected success (table)
Expand Down
5 changes: 4 additions & 1 deletion smartcontract/cli/src/user/request_ban.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ mod tests {

client
.expect_delete_user()
.with(predicate::eq(DeleteUserCommand { pubkey: pda_pubkey }))
.with(predicate::eq(DeleteUserCommand {
pubkey: pda_pubkey,
feed_pk: None,
}))
.returning(move |_| Ok(signature));
client
.expect_list_foundation_allowlist()
Expand Down
8 changes: 8 additions & 0 deletions smartcontract/cli/src/user/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ impl SubscribeUserCliCommand {
client_ip: user.client_ip,
publisher,
subscriber,
device_pk: None,
feed_pk: None,
})?;
writeln!(out, "Updated roles for {group_pk}: {signature}")?;
}
Expand Down Expand Up @@ -218,6 +220,8 @@ mod tests {
client_ip,
publisher: false,
subscriber: true,
device_pk: None,
feed_pk: None,
}))
.times(1)
.returning(move |_| Ok(signature));
Expand Down Expand Up @@ -436,6 +440,8 @@ mod tests {
client_ip,
publisher: false,
subscriber: true,
device_pk: None,
feed_pk: None,
}))
.times(1)
.returning(move |_| Ok(signature));
Expand Down Expand Up @@ -535,6 +541,8 @@ mod tests {
client_ip,
publisher: true,
subscriber: false,
device_pk: None,
feed_pk: None,
}))
.times(1)
.returning(move |_| Ok(signature));
Expand Down
5 changes: 4 additions & 1 deletion smartcontract/cli/src/user/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ mod tests {

client
.expect_delete_user()
.with(predicate::eq(DeleteUserCommand { pubkey: pda_pubkey }))
.with(predicate::eq(DeleteUserCommand {
pubkey: pda_pubkey,
feed_pk: None,
}))
.returning(move |_| Ok(signature));
client
.expect_update_user()
Expand Down
4 changes: 4 additions & 0 deletions smartcontract/programs/doublezero-serviceability/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ pub enum DoubleZeroError {
AccessPassMaxUnicastUsersExceeded, // variant 89
#[error("Access pass max multicast users exceeded")]
AccessPassMaxMulticastUsersExceeded, // variant 90
#[error("Device exchange (metro) is not covered by any feed on the access pass")]
MetroMismatch, // variant 91
}

impl From<DoubleZeroError> for ProgramError {
Expand Down Expand Up @@ -282,6 +284,7 @@ impl From<DoubleZeroError> for ProgramError {
DoubleZeroError::InvalidDeviceTunnelBlock => ProgramError::Custom(88),
DoubleZeroError::AccessPassMaxUnicastUsersExceeded => ProgramError::Custom(89),
DoubleZeroError::AccessPassMaxMulticastUsersExceeded => ProgramError::Custom(90),
DoubleZeroError::MetroMismatch => ProgramError::Custom(91),
}
}
}
Expand Down Expand Up @@ -379,6 +382,7 @@ impl From<u32> for DoubleZeroError {
88 => DoubleZeroError::InvalidDeviceTunnelBlock,
89 => DoubleZeroError::AccessPassMaxUnicastUsersExceeded,
90 => DoubleZeroError::AccessPassMaxMulticastUsersExceeded,
91 => DoubleZeroError::MetroMismatch,
_ => DoubleZeroError::Custom(e),
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,17 @@ pub fn process_set_access_pass(

// Update fields. The max caps are overwritten from args; the live counts are left
// untouched so an in-flight pass keeps its current seat usage.
accesspass.accesspass_type = value.accesspass_type.clone();
//
// EdgeSeat feed seats are owned by SetAccessPassFeeds (the oracle), not this instruction.
// SetAccessPassArgs carries no feed payload, so when both the stored and incoming types are
// EdgeSeat we preserve the provisioned seat vector instead of clobbering it (and its live
// current_users) with the incoming empty vec.
accesspass.accesspass_type = match (&accesspass.accesspass_type, &value.accesspass_type) {
(AccessPassType::EdgeSeat(existing), AccessPassType::EdgeSeat(_)) => {
AccessPassType::EdgeSeat(existing.clone())
}
_ => value.accesspass_type.clone(),
};
accesspass.last_access_epoch = value.last_access_epoch;
accesspass.flags = flags;
accesspass.max_unicast_users = value.max_unicast_users;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,97 @@
pub mod create;
pub mod delete;
pub mod update;

use crate::{
error::DoubleZeroError,
state::{
accesspass::AccessPass,
feed::{Feed, FeedMetroMatch},
},
};
use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult, msg, pubkey::Pubkey};

/// Validate the EdgeSeat feed metro gate without mutating the pass.
///
/// For the device's `device_exchange`, the joinable groups are the matching feed's group set; a
/// device in an exchange not covered by any of the pass's feeds is rejected with `MetroMismatch`. A
/// feed with no metros imposes no restriction (reachable from any exchange, any group).
///
/// `feed_account` must be the `Feed` referenced by one of the pass's seats. `target_mgroup` is the
/// multicast group being joined (None requires only metro coverage).
pub fn check_feed_metro_coverage(
program_id: &Pubkey,
accesspass: &AccessPass,
device_exchange: &Pubkey,
target_mgroup: Option<&Pubkey>,
feed_account: Option<&AccountInfo>,
) -> ProgramResult {
let feed_account = feed_account.ok_or(DoubleZeroError::MetroMismatch)?;
if feed_account.owner != program_id {
return Err(DoubleZeroError::InvalidAccountOwner.into());
}
let feed = Feed::try_from(feed_account)?;

// The feed must be one provisioned onto this pass.
if !accesspass
.feed_seats()
.iter()
.any(|s| s.feed_key == *feed_account.key)
{
msg!(
"Feed {} is not provisioned on the access pass",
feed_account.key
);
return Err(DoubleZeroError::MetroMismatch.into());
}

match feed.groups_for(device_exchange) {
FeedMetroMatch::Unrestricted => {}
FeedMetroMatch::Groups(groups) => {
if let Some(group) = target_mgroup {
if !groups.contains(group) {
msg!(
"Group {} not joinable for exchange {} via feed {}",
group,
device_exchange,
feed_account.key
);
return Err(DoubleZeroError::MetroMismatch.into());
}
}
}
FeedMetroMatch::NotCovered => {
msg!(
"Device exchange {} not covered by feed {}",
device_exchange,
feed_account.key
);
return Err(DoubleZeroError::MetroMismatch.into());
}
}

Ok(())
}

/// Enforce the EdgeSeat feed metro gate at connect and tick the matching feed seat against its cap.
/// Call only for EdgeSeat passes. See [`check_feed_metro_coverage`].
pub fn enforce_feed_metro_gate(
program_id: &Pubkey,
accesspass: &mut AccessPass,
device_exchange: &Pubkey,
target_mgroup: Option<&Pubkey>,
feed_account: Option<&AccountInfo>,
) -> ProgramResult {
check_feed_metro_coverage(
program_id,
accesspass,
device_exchange,
target_mgroup,
feed_account,
)?;
// feed_account is guaranteed Some here (check returns Err otherwise).
if let Some(feed_account) = feed_account {
accesspass.try_add_feed_user(feed_account.key)?;
}
Ok(())
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ use crate::{
error::DoubleZeroError,
pda::{get_accesspass_pda, get_globalstate_pda, get_resource_extension_pda},
processors::{
feed::check_feed_metro_coverage,
resource::{allocate_ip, deallocate_ip},
validation::validate_program_account,
},
resource::ResourceType,
serializer::try_acc_write,
state::{
accesspass::AccessPass,
accesspass::{AccessPass, AccessPassType},
device::Device,
globalstate::GlobalState,
multicastgroup::{MulticastGroup, MulticastGroupStatus},
permission::permission_flags,
Expand Down Expand Up @@ -74,12 +76,16 @@ pub fn update_user_multicastgroup_roles(
return Err(DoubleZeroError::InvalidStatus.into());
}

// Check allowlists for additions
if publisher && !accesspass.mgroup_pub_allowlist.contains(mgroup_account.key) {
// Check allowlists for additions. EdgeSeat passes derive joinable groups from their feeds'
// metro→group map (the feed metro gate), which supersedes the mgroup allowlist; the caller is
// responsible for running enforce_feed_metro_gate for EdgeSeat connects.
let is_edge_seat = matches!(accesspass.accesspass_type, AccessPassType::EdgeSeat(_));
if publisher && !is_edge_seat && !accesspass.mgroup_pub_allowlist.contains(mgroup_account.key) {
msg!("{:?}", accesspass);
return Err(DoubleZeroError::NotAllowed.into());
}
if subscriber && !accesspass.mgroup_sub_allowlist.contains(mgroup_account.key) {
if subscriber && !is_edge_seat && !accesspass.mgroup_sub_allowlist.contains(mgroup_account.key)
{
msg!("{:?}", accesspass);
return Err(DoubleZeroError::NotAllowed.into());
}
Expand Down Expand Up @@ -238,6 +244,33 @@ pub fn process_update_multicastgroup_roles(
}
}

// Optional trailing accounts for the EdgeSeat feed metro gate: the user's device (for its
// exchange) and the Feed covering it. Read AFTER the authorize() above so they do not consume
// the optional trailing Permission account.
let device_account = accounts_iter.next();
let feed_account = accounts_iter.next();

// EdgeSeat passes derive joinable groups from their feeds' metro→group map. The seat was
// ticked at connect (CreateSubscribeUser), so post-activation role adds only re-validate
// coverage; they do not re-tick.
if matches!(accesspass.accesspass_type, AccessPassType::EdgeSeat(_))
&& (value.publisher || value.subscriber)
{
let device_account = device_account.ok_or(DoubleZeroError::MetroMismatch)?;
validate_program_account!(device_account, program_id, writable = false, "Device");
if user.device_pk != *device_account.key {
return Err(ProgramError::InvalidAccountData);
}
let device = Device::try_from(device_account)?;
check_feed_metro_coverage(
program_id,
&accesspass,
&device.exchange_pk,
Some(mgroup_account.key),
feed_account,
)?;
}

let result = update_user_multicastgroup_roles(
mgroup_account,
&accesspass,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ pub fn process_create_user(
value.tunnel_endpoint,
false,
None,
// Plain CreateUser is unicast; no multicast group and no feed gate.
None,
None,
)?;

// Always allocate resources and activate atomically.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ use solana_program::{
};
use std::net::Ipv4Addr;

use crate::{processors::validation::validate_program_account, serializer::try_acc_write};
use crate::{
processors::{feed::enforce_feed_metro_gate, validation::validate_program_account},
serializer::try_acc_write,
};

#[derive(PartialEq)]
pub enum PDAVersion {
Expand Down Expand Up @@ -68,6 +71,10 @@ pub fn create_user_core(
tunnel_endpoint: Ipv4Addr,
is_publisher: bool,
owner_override: Option<Pubkey>,
// EdgeSeat multicast metro gate: the multicast group being joined (None for non-multicast
// connects) and the referenced Feed account covering the device's exchange.
target_mgroup: Option<&Pubkey>,
feed_account: Option<&AccountInfo>,
) -> Result<CreateUserCoreResult, ProgramError> {
// Check if the payer is a signer
assert!(core.payer_account.is_signer, "Payer must be a signer");
Expand Down Expand Up @@ -295,6 +302,21 @@ pub fn create_user_core(
// returns before any account is written, so no state is persisted.
accesspass.try_add_user(user_type)?;

// EdgeSeat multicast metro gate: the device's exchange must be covered by a feed on the pass,
// the target group must be joinable there, and that feed's seat is ticked. Unicast retains the
// per-category cap above and is not feed-gated.
if matches!(accesspass.accesspass_type, AccessPassType::EdgeSeat(_))
&& user_type == UserType::Multicast
{
enforce_feed_metro_gate(
program_id,
&mut accesspass,
&device.exchange_pk,
target_mgroup,
feed_account,
)?;
}

// All validations passed - now update counters
accesspass.connection_count += 1;
accesspass.status = AccessPassStatus::Connected;
Expand Down
Loading
Loading