From 442209fe59c79ced1f867ad5507193f424ec6c13 Mon Sep 17 00:00:00 2001 From: Nik Weidenbacher Date: Tue, 30 Jun 2026 17:25:08 +0000 Subject: [PATCH] serviceability: enforce EdgeSeat feed metro gate at connect Add MetroMismatch and the per-feed metro gate: at multicast connect a device whose exchange is not covered by any feed on the pass is rejected, joinable groups come from the matching feed's group set, and the feed seat is ticked (max_multicast_users superseded). Threads an optional Feed account through create/subscribe/delete. --- CHANGELOG.md | 1 + smartcontract/cli/src/tenant/delete.rs | 5 +- .../cli/src/user/create_subscribe.rs | 2 + smartcontract/cli/src/user/delete.rs | 10 +- smartcontract/cli/src/user/get.rs | 5 +- smartcontract/cli/src/user/request_ban.rs | 5 +- smartcontract/cli/src/user/subscribe.rs | 8 + smartcontract/cli/src/user/update.rs | 5 +- .../doublezero-serviceability/src/error.rs | 4 + .../src/processors/accesspass/set.rs | 12 +- .../src/processors/feed/mod.rs | 94 ++++ .../processors/multicastgroup/subscribe.rs | 41 +- .../src/processors/user/create.rs | 3 + .../src/processors/user/create_core.rs | 24 +- .../src/processors/user/create_subscribe.rs | 6 + .../src/processors/user/delete.rs | 11 + .../src/state/accesspass.rs | 121 +++-- .../tests/delete_user_dynamic_accesspass.rs | 34 +- .../tests/feed_metro_gate_test.rs | 483 ++++++++++++++++++ .../src/commands/multicastgroup/subscribe.rs | 23 +- .../sdk/rs/src/commands/tenant/delete.rs | 6 +- .../rs/src/commands/user/create_subscribe.rs | 10 + .../sdk/rs/src/commands/user/delete.rs | 24 + .../sdk/rs/src/commands/user/requestban.rs | 2 + 24 files changed, 870 insertions(+), 69 deletions(-) create mode 100644 smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c392f925..9b59ec3ea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/smartcontract/cli/src/tenant/delete.rs b/smartcontract/cli/src/tenant/delete.rs index fc0b89cbc6..e7efcc12db 100644 --- a/smartcontract/cli/src/tenant/delete.rs +++ b/smartcontract/cli/src/tenant/delete.rs @@ -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); } diff --git a/smartcontract/cli/src/user/create_subscribe.rs b/smartcontract/cli/src/user/create_subscribe.rs index 541e41f547..b19c3cfaec 100644 --- a/smartcontract/cli/src/user/create_subscribe.rs +++ b/smartcontract/cli/src/user/create_subscribe.rs @@ -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}",)?; @@ -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))); diff --git a/smartcontract/cli/src/user/delete.rs b/smartcontract/cli/src/user/delete.rs index 16fcb358c6..a16ce1b2a1 100644 --- a/smartcontract/cli/src/user/delete.rs +++ b/smartcontract/cli/src/user/delete.rs @@ -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(()) @@ -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)); /*****************************************************************************************************/ diff --git a/smartcontract/cli/src/user/get.rs b/smartcontract/cli/src/user/get.rs index 0373d2ab97..fc9b1d5225 100644 --- a/smartcontract/cli/src/user/get.rs +++ b/smartcontract/cli/src/user/get.rs @@ -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) diff --git a/smartcontract/cli/src/user/request_ban.rs b/smartcontract/cli/src/user/request_ban.rs index 6b8d42712b..825e3bb2fb 100644 --- a/smartcontract/cli/src/user/request_ban.rs +++ b/smartcontract/cli/src/user/request_ban.rs @@ -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() diff --git a/smartcontract/cli/src/user/subscribe.rs b/smartcontract/cli/src/user/subscribe.rs index 59fd365baf..52858b8af4 100644 --- a/smartcontract/cli/src/user/subscribe.rs +++ b/smartcontract/cli/src/user/subscribe.rs @@ -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}")?; } @@ -218,6 +220,8 @@ mod tests { client_ip, publisher: false, subscriber: true, + device_pk: None, + feed_pk: None, })) .times(1) .returning(move |_| Ok(signature)); @@ -436,6 +440,8 @@ mod tests { client_ip, publisher: false, subscriber: true, + device_pk: None, + feed_pk: None, })) .times(1) .returning(move |_| Ok(signature)); @@ -535,6 +541,8 @@ mod tests { client_ip, publisher: true, subscriber: false, + device_pk: None, + feed_pk: None, })) .times(1) .returning(move |_| Ok(signature)); diff --git a/smartcontract/cli/src/user/update.rs b/smartcontract/cli/src/user/update.rs index bc2c329844..80a6edf3e0 100644 --- a/smartcontract/cli/src/user/update.rs +++ b/smartcontract/cli/src/user/update.rs @@ -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() diff --git a/smartcontract/programs/doublezero-serviceability/src/error.rs b/smartcontract/programs/doublezero-serviceability/src/error.rs index 48987e23c3..ae090b7852 100644 --- a/smartcontract/programs/doublezero-serviceability/src/error.rs +++ b/smartcontract/programs/doublezero-serviceability/src/error.rs @@ -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 for ProgramError { @@ -282,6 +284,7 @@ impl From for ProgramError { DoubleZeroError::InvalidDeviceTunnelBlock => ProgramError::Custom(88), DoubleZeroError::AccessPassMaxUnicastUsersExceeded => ProgramError::Custom(89), DoubleZeroError::AccessPassMaxMulticastUsersExceeded => ProgramError::Custom(90), + DoubleZeroError::MetroMismatch => ProgramError::Custom(91), } } } @@ -379,6 +382,7 @@ impl From for DoubleZeroError { 88 => DoubleZeroError::InvalidDeviceTunnelBlock, 89 => DoubleZeroError::AccessPassMaxUnicastUsersExceeded, 90 => DoubleZeroError::AccessPassMaxMulticastUsersExceeded, + 91 => DoubleZeroError::MetroMismatch, _ => DoubleZeroError::Custom(e), } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set.rs b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set.rs index a92a216337..47d72eb900 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set.rs @@ -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; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs index fdb2f55613..09d87e5a09 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs @@ -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(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs index 61b3d251fc..78ae6f5819 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs @@ -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, @@ -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()); } @@ -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, diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/create.rs index e8a1596230..43ddf8beac 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/create.rs @@ -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. diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs index 03ce9e92b3..31291085f3 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs @@ -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 { @@ -68,6 +71,10 @@ pub fn create_user_core( tunnel_endpoint: Ipv4Addr, is_publisher: bool, owner_override: Option, + // 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 { // Check if the payer is a signer assert!(core.payer_account.is_signer, "Payer must be a signer"); @@ -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; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/create_subscribe.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/create_subscribe.rs index 7da06c3ce4..d3fdac21a3 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/create_subscribe.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/create_subscribe.rs @@ -93,6 +93,10 @@ pub fn process_create_subscribe_user( let payer_account = next_account_info(accounts_iter)?; let system_program = next_account_info(accounts_iter)?; + // Optional trailing Feed account for the EdgeSeat metro gate: the feed (referenced by the pass) + // that covers the device's exchange and lists the target multicast group. + let feed_account = accounts_iter.next(); + msg!("process_create_subscribe_user({:?})", value); let core_accounts = CreateUserCoreAccounts { @@ -120,6 +124,8 @@ pub fn process_create_subscribe_user( value.tunnel_endpoint, value.publisher, owner_override, + Some(mgroup_account.key), + feed_account, )?; // Subscribe user to multicast group diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs index f1a6c18945..994d1cb808 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs @@ -141,6 +141,10 @@ pub fn process_delete_user( )?; } + // Optional trailing Feed account: releases the EdgeSeat feed seat held by this user. Read AFTER + // authorize so it does not consume the optional trailing Permission account. + let feed_account = accounts_iter.next(); + let (accesspass_pda, _) = get_accesspass_pda(program_id, &user.client_ip, &user.owner); let (accesspass_dynamic_pda, _) = get_accesspass_pda(program_id, &Ipv4Addr::UNSPECIFIED, &user.owner); @@ -180,6 +184,13 @@ pub fn process_delete_user( accesspass.connection_count = accesspass.connection_count.saturating_sub(1); // Release the per-category seat (EdgeSeat only; no-op otherwise). accesspass.remove_user(user.user_type); + // Release the feed-scoped seat for EdgeSeat multicast users (no-op if no feed supplied or + // the feed is not on the pass). + if user.user_type == UserType::Multicast { + if let Some(feed) = feed_account { + accesspass.remove_feed_user(feed.key); + } + } accesspass.status = if accesspass.connection_count > 0 { AccessPassStatus::Connected } else { diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accesspass.rs b/smartcontract/programs/doublezero-serviceability/src/state/accesspass.rs index d8530345b8..f7587641a5 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accesspass.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accesspass.rs @@ -297,40 +297,36 @@ impl AccessPass { /// Admit a user against the per-category seat caps. EdgeSeat-only: for all other access-pass /// types this is a no-op and always succeeds. Does NOT touch `connection_count` — that counter /// is maintained independently by the user create/delete processors. + /// + /// Per the feed-scoped supersede model (#1700): for EdgeSeat **multicast** the authoritative + /// cap is the per-feed [`FeedSeat`] (see [`Self::try_add_feed_user`]), so `max_multicast_users` + /// is no longer enforced here and is retained only for layout/back-compat. The per-category + /// **unicast** cap is still enforced. pub fn try_add_user(&mut self, user_type: UserType) -> Result<(), DoubleZeroError> { if !matches!(self.accesspass_type, AccessPassType::EdgeSeat(_)) { return Ok(()); } match user_type { - UserType::Multicast => { - if self.multicast_user_count >= self.max_multicast_users { - return Err(DoubleZeroError::AccessPassMaxMulticastUsersExceeded); - } - self.multicast_user_count += 1; - } + // Vestigial: gated by FeedSeat caps instead. See try_add_feed_user. + UserType::Multicast => Ok(()), _ => { if self.unicast_user_count >= self.max_unicast_users { return Err(DoubleZeroError::AccessPassMaxUnicastUsersExceeded); } self.unicast_user_count += 1; + Ok(()) } } - Ok(()) } /// Release a seat held by a user. EdgeSeat-only: no-op for all other access-pass types. Does NOT - /// touch `connection_count`. + /// touch `connection_count`. Multicast release is feed-scoped (see [`Self::remove_feed_user`]). pub fn remove_user(&mut self, user_type: UserType) { if !matches!(self.accesspass_type, AccessPassType::EdgeSeat(_)) { return; } - match user_type { - UserType::Multicast => { - self.multicast_user_count = self.multicast_user_count.saturating_sub(1); - } - _ => { - self.unicast_user_count = self.unicast_user_count.saturating_sub(1); - } + if user_type != UserType::Multicast { + self.unicast_user_count = self.unicast_user_count.saturating_sub(1); } } @@ -341,6 +337,36 @@ impl AccessPass { _ => &[], } } + + fn feed_seat_mut(&mut self, feed_key: &Pubkey) -> Option<&mut FeedSeat> { + match &mut self.accesspass_type { + AccessPassType::EdgeSeat(seats) => seats.iter_mut().find(|s| s.feed_key == *feed_key), + _ => None, + } + } + + /// Tick the matching feed seat's `current_users` against its `max_users`. Returns + /// `MetroMismatch` if the pass carries no seat for `feed_key`, or + /// `AccessPassMaxMulticastUsersExceeded` if the feed cap is full. + pub fn try_add_feed_user(&mut self, feed_key: &Pubkey) -> Result<(), DoubleZeroError> { + match self.feed_seat_mut(feed_key) { + Some(seat) => { + if seat.current_users >= seat.max_users { + return Err(DoubleZeroError::AccessPassMaxMulticastUsersExceeded); + } + seat.current_users += 1; + Ok(()) + } + None => Err(DoubleZeroError::MetroMismatch), + } + } + + /// Release a seat held against `feed_key`. No-op if the feed is not on the pass. + pub fn remove_feed_user(&mut self, feed_key: &Pubkey) { + if let Some(seat) = self.feed_seat_mut(feed_key) { + seat.current_users = seat.current_users.saturating_sub(1); + } + } } #[cfg(test)] @@ -515,10 +541,10 @@ mod tests { } #[test] - fn test_edge_seat_user_caps() { + fn test_edge_seat_unicast_cap_retained() { let mut ap = test_accesspass(AccessPassType::EdgeSeat(vec![])); - // Unicast: cap is 2. + // Unicast: per-category cap is still enforced (cap is 2). ap.try_add_user(UserType::IBRL).unwrap(); ap.try_add_user(UserType::EdgeFiltering).unwrap(); assert_eq!(ap.unicast_user_count, 2); @@ -527,27 +553,64 @@ mod tests { DoubleZeroError::AccessPassMaxUnicastUsersExceeded ); - // Multicast: cap is 1. + // Multicast: max_multicast_users is vestigial under supersede — try_add_user is a no-op + // and never errors, regardless of max_multicast_users. + ap.max_multicast_users = 0; ap.try_add_user(UserType::Multicast).unwrap(); - assert_eq!(ap.multicast_user_count, 1); - assert_eq!( - ap.try_add_user(UserType::Multicast).unwrap_err(), - DoubleZeroError::AccessPassMaxMulticastUsersExceeded - ); + ap.try_add_user(UserType::Multicast).unwrap(); + assert_eq!(ap.multicast_user_count, 0); - // remove_user frees a seat in the matching category. ap.remove_user(UserType::IBRL); assert_eq!(ap.unicast_user_count, 1); - ap.remove_user(UserType::Multicast); - assert_eq!(ap.multicast_user_count, 0); - // saturating: never underflows below 0. - ap.remove_user(UserType::Multicast); - assert_eq!(ap.multicast_user_count, 0); // connection_count is never touched by the seat helpers. assert_eq!(ap.connection_count, 0); } + #[test] + fn test_edge_seat_feed_caps() { + let feed_a = Pubkey::new_unique(); + let feed_b = Pubkey::new_unique(); + let mut ap = test_accesspass(AccessPassType::EdgeSeat(vec![ + FeedSeat { + feed_key: feed_a, + max_users: 2, + current_users: 0, + }, + FeedSeat { + feed_key: feed_b, + max_users: 1, + current_users: 0, + }, + ])); + + // feed_a admits 2 then rejects. + ap.try_add_feed_user(&feed_a).unwrap(); + ap.try_add_feed_user(&feed_a).unwrap(); + assert_eq!(ap.feed_seats()[0].current_users, 2); + assert_eq!( + ap.try_add_feed_user(&feed_a).unwrap_err(), + DoubleZeroError::AccessPassMaxMulticastUsersExceeded + ); + + // feed_b is independent. + ap.try_add_feed_user(&feed_b).unwrap(); + assert_eq!(ap.feed_seats()[1].current_users, 1); + + // Unknown feed → MetroMismatch. + assert_eq!( + ap.try_add_feed_user(&Pubkey::new_unique()).unwrap_err(), + DoubleZeroError::MetroMismatch + ); + + // Release frees a seat; saturating. + ap.remove_feed_user(&feed_a); + assert_eq!(ap.feed_seats()[0].current_users, 1); + ap.remove_feed_user(&feed_b); + ap.remove_feed_user(&feed_b); + assert_eq!(ap.feed_seats()[1].current_users, 0); + } + #[test] fn test_non_edge_seat_user_caps_are_noop() { for accesspass_type in [ diff --git a/smartcontract/programs/doublezero-serviceability/tests/delete_user_dynamic_accesspass.rs b/smartcontract/programs/doublezero-serviceability/tests/delete_user_dynamic_accesspass.rs index ef903f99a3..4d3170f6f4 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/delete_user_dynamic_accesspass.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/delete_user_dynamic_accesspass.rs @@ -682,9 +682,10 @@ async fn try_create_user( .await } -/// EdgeSeat passes admit at most `max_unicast_users` unicast and `max_multicast_users` multicast -/// users; the (N+1)th in each category is rejected with the per-category error. The pass lives at -/// the UNSPECIFIED PDA so distinct client IPs all map to the same seat. +/// EdgeSeat passes admit at most `max_unicast_users` unicast users; the (N+1)th is rejected with +/// the per-category error. Multicast is feed-scoped (supersede): with no feeds provisioned on the +/// pass, a multicast connect is rejected with `MetroMismatch`. The pass lives at the UNSPECIFIED PDA +/// so distinct client IPs all map to the same seat. #[tokio::test] async fn test_edge_seat_user_caps_enforced() { let mut env = setup_test_env().await; @@ -739,37 +740,28 @@ async fn test_edge_seat_user_caps_enforced() { "expected AccessPassMaxUnicastUsersExceeded (Custom(89)), got: {err:?}" ); - // Multicast is a separate category, so the first multicast user is still admitted. - try_create_user( - &mut env, - [100, 0, 0, 12].into(), - UserType::Multicast, - accesspass_pubkey, - ) - .await - .expect("first multicast user should be admitted"); - - // Second multicast user exceeds the multicast cap. + // Multicast is feed-scoped under supersede. With no feeds provisioned on the pass, a multicast + // connect is rejected with MetroMismatch (Custom(91)) rather than the legacy multicast cap. let err = try_create_user( &mut env, - [100, 0, 0, 13].into(), + [100, 0, 0, 12].into(), UserType::Multicast, accesspass_pubkey, ) .await - .expect_err("second multicast user should exceed the cap"); + .expect_err("multicast on a feedless EdgeSeat pass should be rejected"); assert!( - format!("{err:?}").contains("Custom(90)"), - "expected AccessPassMaxMulticastUsersExceeded (Custom(90)), got: {err:?}" + format!("{err:?}").contains("Custom(91)"), + "expected MetroMismatch (Custom(91)), got: {err:?}" ); - // The pass tracks one seat per category; connection_count counts both connections. + // Only the unicast connection was admitted. let pass = get_account_data(&mut env.banks_client, accesspass_pubkey) .await .unwrap() .get_accesspass() .unwrap(); assert_eq!(pass.unicast_user_count, 1); - assert_eq!(pass.multicast_user_count, 1); - assert_eq!(pass.connection_count, 2); + assert_eq!(pass.multicast_user_count, 0); + assert_eq!(pass.connection_count, 1); } diff --git a/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs b/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs new file mode 100644 index 0000000000..5f7a28b378 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs @@ -0,0 +1,483 @@ +//! Integration tests for the EdgeSeat feed metro gate (#1700). +//! +//! Scenarios: +//! - wrong-metro device rejected (MetroMismatch) +//! - right-metro joins the metro's group set +//! - multi-feed seat (matching feed admits) +//! - no-metro feed reachable from anywhere + +use doublezero_serviceability::{ + entrypoint::process_instruction, + instructions::DoubleZeroInstruction, + pda::{ + get_accesspass_pda, get_contributor_pda, get_device_pda, get_exchange_pda, get_feed_pda, + get_globalconfig_pda, get_globalstate_pda, get_location_pda, get_multicastgroup_pda, + get_resource_extension_pda, get_user_pda, + }, + processors::{ + accesspass::{set::SetAccessPassArgs, set_feeds::SetAccessPassFeedsArgs}, + contributor::create::ContributorCreateArgs, + device::{create::DeviceCreateArgs, update::DeviceUpdateArgs}, + exchange::create::ExchangeCreateArgs, + feed::create::FeedCreateArgs, + location::create::LocationCreateArgs, + multicastgroup::create::MulticastGroupCreateArgs, + user::create_subscribe::UserCreateSubscribeArgs, + }, + resource::ResourceType, + state::{ + accesspass::{AccessPassType, FeedSeat}, + device::DeviceType, + user::{UserCYOA, UserStatus, UserType}, + }, +}; +use solana_program_test::*; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signer}; +use std::net::Ipv4Addr; + +mod test_helpers; +use test_helpers::*; + +struct FeedFixture { + banks_client: BanksClient, + payer: solana_sdk::signature::Keypair, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + exchange_pubkey: Pubkey, + device_pubkey: Pubkey, + accesspass_pubkey: Pubkey, + mgroup_pubkey: Pubkey, + user_ip: Ipv4Addr, + user_tunnel_block: Pubkey, + multicast_publisher_block: Pubkey, + tunnel_ids: Pubkey, + dz_prefix_block: Pubkey, +} + +/// Build GlobalState/Config, Location, Exchange, Contributor, an Activated Device, an Activated +/// MulticastGroup, and an EdgeSeat access pass (no feeds yet — provisioned per test). +async fn setup_feed_fixture(client_ip: [u8; 4]) -> FeedFixture { + let program_id = Pubkey::new_unique(); + let mut program_test = ProgramTest::new( + "doublezero_serviceability", + program_id, + processor!(process_instruction), + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; + + let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let (user_tunnel_block, _, _) = + get_resource_extension_pda(&program_id, ResourceType::UserTunnelBlock); + let (multicast_publisher_block, _, _) = + get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); + + init_globalstate_and_config(&mut banks_client, program_id, &payer, recent_blockhash).await; + + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, gs.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "test".to_string(), + name: "Test Location".to_string(), + country: "us".to_string(), + lat: 0.0, + lng: 0.0, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, gs.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "test".to_string(), + name: "Test Exchange".to_string(), + lat: 0.0, + lng: 0.0, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = get_contributor_pda(&program_id, gs.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "test".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, gs.account_index + 1); + let (tunnel_ids, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_pubkey, 0)); + let (dz_prefix_block, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_pubkey, 0)); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "test-dev".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [100, 0, 0, 1].into(), + dz_prefixes: "110.1.0.0/24".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: None, + resource_count: 2, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(tunnel_ids, false), + AccountMeta::new(dz_prefix_block, false), + ], + &payer, + ) + .await; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDevice(DeviceUpdateArgs { + max_users: Some(128), + ..DeviceUpdateArgs::default() + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (mgroup_pubkey, _) = get_multicastgroup_pda(&program_id, gs.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateMulticastGroup(MulticastGroupCreateArgs { + code: "group1".to_string(), + max_bandwidth: 1000, + owner: payer.pubkey(), + use_onchain_allocation: true, + }), + vec![ + AccountMeta::new(mgroup_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new( + get_resource_extension_pda(&program_id, ResourceType::MulticastGroupBlock).0, + false, + ), + ], + &payer, + ) + .await; + + // EdgeSeat access pass with no feeds yet. + let user_ip: Ipv4Addr = client_ip.into(); + let (accesspass_pubkey, _) = get_accesspass_pda(&program_id, &user_ip, &payer.pubkey()); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetAccessPass(SetAccessPassArgs { + accesspass_type: AccessPassType::EdgeSeat(vec![]), + client_ip: user_ip, + last_access_epoch: 9999, + allow_multiple_ip: false, + max_unicast_users: 1, + max_multicast_users: 1, + }), + vec![ + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + ], + &payer, + ) + .await; + + FeedFixture { + banks_client, + payer, + program_id, + globalstate_pubkey, + exchange_pubkey, + device_pubkey, + accesspass_pubkey, + mgroup_pubkey, + user_ip, + user_tunnel_block, + multicast_publisher_block, + tunnel_ids, + dz_prefix_block, + } +} + +/// Create a feed (catalog admin = foundation payer) with the given metro map. +async fn create_feed( + f: &mut FeedFixture, + code: &str, + metros: Vec<(Pubkey, Vec)>, +) -> Pubkey { + let (feed_pubkey, _) = get_feed_pda(&f.program_id, code); + let recent_blockhash = f.banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut f.banks_client, + recent_blockhash, + f.program_id, + DoubleZeroInstruction::CreateFeed(FeedCreateArgs { + code: code.to_string(), + name: code.to_string(), + metros, + }), + vec![ + AccountMeta::new(feed_pubkey, false), + AccountMeta::new(f.globalstate_pubkey, false), + ], + &f.payer, + ) + .await; + feed_pubkey +} + +/// Provision the given feed seats onto the access pass via SetAccessPassFeeds. +async fn set_pass_feeds(f: &mut FeedFixture, seats: Vec) { + let recent_blockhash = f.banks_client.get_latest_blockhash().await.unwrap(); + let accounts = vec![ + AccountMeta::new(f.accesspass_pubkey, false), + AccountMeta::new(f.globalstate_pubkey, false), + ]; + // Feed accounts (writable for reference_count bump) follow payer + system; they are passed as + // extra accounts. + let extra: Vec = seats + .iter() + .map(|s| AccountMeta::new(s.feed_key, false)) + .collect(); + let mut tx = create_transaction_with_extra_accounts( + f.program_id, + &DoubleZeroInstruction::SetAccessPassFeeds(SetAccessPassFeedsArgs { + client_ip: f.user_ip, + user_payer: f.payer.pubkey(), + feeds: seats, + }), + &accounts, + &f.payer, + &extra, + ); + tx.try_sign(&[&f.payer], recent_blockhash).unwrap(); + f.banks_client.process_transaction(tx).await.unwrap(); +} + +/// Attempt CreateSubscribeUser as a subscriber, passing `feed` as the trailing metro-gate account. +async fn try_subscribe_with_feed( + f: &mut FeedFixture, + feed: Pubkey, +) -> Result<(), BanksClientError> { + let recent_blockhash = wait_for_new_blockhash(&mut f.banks_client).await; + let (user_pubkey, _) = get_user_pda(&f.program_id, &f.user_ip, UserType::Multicast); + let accounts = vec![ + AccountMeta::new(user_pubkey, false), + AccountMeta::new(f.device_pubkey, false), + AccountMeta::new(f.mgroup_pubkey, false), + AccountMeta::new(f.accesspass_pubkey, false), + AccountMeta::new(f.globalstate_pubkey, false), + AccountMeta::new(f.user_tunnel_block, false), + AccountMeta::new(f.multicast_publisher_block, false), + AccountMeta::new(f.tunnel_ids, false), + AccountMeta::new(f.dz_prefix_block, false), + ]; + let mut tx = create_transaction_with_extra_accounts( + f.program_id, + &DoubleZeroInstruction::CreateSubscribeUser(UserCreateSubscribeArgs { + user_type: UserType::Multicast, + cyoa_type: UserCYOA::GREOverDIA, + client_ip: f.user_ip, + publisher: false, + subscriber: true, + tunnel_endpoint: Ipv4Addr::UNSPECIFIED, + dz_prefix_count: 1, + owner: Pubkey::default(), + }), + &accounts, + &f.payer, + &[AccountMeta::new_readonly(feed, false)], + ); + tx.try_sign(&[&f.payer], recent_blockhash).unwrap(); + f.banks_client.process_transaction(tx).await +} + +#[tokio::test] +async fn test_right_metro_joins_group_set() { + let mut f = setup_feed_fixture([100, 0, 0, 20]).await; + let (exchange, mgroup) = (f.exchange_pubkey, f.mgroup_pubkey); + // Feed maps the device's exchange → [mgroup]. + let feed = create_feed(&mut f, "shreds", vec![(exchange, vec![mgroup])]).await; + set_pass_feeds( + &mut f, + vec![FeedSeat { + feed_key: feed, + max_users: 2, + current_users: 0, + }], + ) + .await; + + try_subscribe_with_feed(&mut f, feed) + .await + .expect("right-metro subscribe should succeed"); + + let (user_pubkey, _) = get_user_pda(&f.program_id, &f.user_ip, UserType::Multicast); + let user = get_account_data(&mut f.banks_client, user_pubkey) + .await + .expect("user exists") + .get_user() + .unwrap(); + assert_eq!(user.status, UserStatus::Activated); + assert_eq!(user.subscribers, vec![f.mgroup_pubkey]); + + // The feed seat was ticked. + let pass = get_account_data(&mut f.banks_client, f.accesspass_pubkey) + .await + .unwrap() + .get_accesspass() + .unwrap(); + assert_eq!(pass.feed_seats()[0].current_users, 1); +} + +#[tokio::test] +async fn test_wrong_metro_device_rejected() { + let mut f = setup_feed_fixture([100, 0, 0, 21]).await; + let mgroup = f.mgroup_pubkey; + // Feed covers a DIFFERENT exchange, so the device's metro is not covered. + let other_exchange = Pubkey::new_unique(); + let feed = create_feed(&mut f, "shreds", vec![(other_exchange, vec![mgroup])]).await; + set_pass_feeds( + &mut f, + vec![FeedSeat { + feed_key: feed, + max_users: 2, + current_users: 0, + }], + ) + .await; + + let err = try_subscribe_with_feed(&mut f, feed) + .await + .expect_err("wrong-metro subscribe should be rejected"); + assert!( + format!("{err:?}").contains("Custom(91)"), + "expected MetroMismatch (Custom(91)), got: {err:?}" + ); +} + +#[tokio::test] +async fn test_multi_feed_seat_matching_admits() { + let mut f = setup_feed_fixture([100, 0, 0, 22]).await; + let (exchange, mgroup) = (f.exchange_pubkey, f.mgroup_pubkey); + // Two feeds: one covering a bogus metro, one covering the device's metro. + let feed_other = create_feed(&mut f, "tokyo", vec![(Pubkey::new_unique(), vec![mgroup])]).await; + let feed_match = create_feed(&mut f, "fra", vec![(exchange, vec![mgroup])]).await; + set_pass_feeds( + &mut f, + vec![ + FeedSeat { + feed_key: feed_other, + max_users: 1, + current_users: 0, + }, + FeedSeat { + feed_key: feed_match, + max_users: 1, + current_users: 0, + }, + ], + ) + .await; + + // Subscribing with the matching feed succeeds. + try_subscribe_with_feed(&mut f, feed_match) + .await + .expect("subscribe via the matching feed should succeed"); + + let pass = get_account_data(&mut f.banks_client, f.accesspass_pubkey) + .await + .unwrap() + .get_accesspass() + .unwrap(); + let matched = pass + .feed_seats() + .iter() + .find(|s| s.feed_key == feed_match) + .unwrap(); + assert_eq!(matched.current_users, 1); +} + +#[tokio::test] +async fn test_no_metro_feed_reachable_from_anywhere() { + let mut f = setup_feed_fixture([100, 0, 0, 23]).await; + // Feed with no metros: no restriction, reachable from any exchange and any group. + let feed = create_feed(&mut f, "global", vec![]).await; + set_pass_feeds( + &mut f, + vec![FeedSeat { + feed_key: feed, + max_users: 2, + current_users: 0, + }], + ) + .await; + + try_subscribe_with_feed(&mut f, feed) + .await + .expect("no-metro feed should be reachable"); + + let (user_pubkey, _) = get_user_pda(&f.program_id, &f.user_ip, UserType::Multicast); + let user = get_account_data(&mut f.banks_client, user_pubkey) + .await + .expect("user exists") + .get_user() + .unwrap(); + assert_eq!(user.status, UserStatus::Activated); +} diff --git a/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe.rs b/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe.rs index db46505f21..4c1e1b78a8 100644 --- a/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe.rs +++ b/smartcontract/sdk/rs/src/commands/multicastgroup/subscribe.rs @@ -21,6 +21,11 @@ pub struct UpdateMulticastGroupRolesCommand { pub user_pk: Pubkey, pub publisher: bool, pub subscriber: bool, + /// Reserved for the EdgeSeat feed metro gate (the user's device + covering Feed). Not appended + /// by this builder — the authorized-transaction layout has no slot after the trailing + /// `[payer, system, permission]`; post-activation re-gating is deferred to #1699. + pub device_pk: Option, + pub feed_pk: Option, } impl UpdateMulticastGroupRolesCommand { @@ -65,7 +70,7 @@ impl UpdateMulticastGroupRolesCommand { &client.get_program_id(), ResourceType::MulticastPublisherBlock, ); - let accounts = vec![ + let mut accounts = vec![ AccountMeta::new(self.group_pk, false), AccountMeta::new(accesspass_pubkey, false), AccountMeta::new(self.user_pk, false), @@ -73,11 +78,15 @@ impl UpdateMulticastGroupRolesCommand { AccountMeta::new(multicast_publisher_block_ext, false), ]; - // Use the authorized path so the payer's Permission account is appended when - // it exists on-chain. Removal-only cleanup (e.g. DeleteUserCommand / - // RequestBanUserCommand) is authorized via USER_ADMIN when the payer is - // neither the access-pass owner nor a foundation member; for owner/foundation - // callers the (optional) trailing account is simply ignored on-chain. + // Use the authorized path so the payer's Permission account is appended when it exists + // on-chain. Removal-only cleanup (DeleteUserCommand / RequestBanUserCommand) is authorized + // via USER_ADMIN when the payer is neither the access-pass owner nor a foundation member. + // + // The EdgeSeat feed metro gate is enforced at connect (CreateSubscribeUser). The optional + // `device_pk`/`feed_pk` for post-activation re-gating are NOT appended here: the on-chain + // trailing `[payer, system, permission]` layout (see `assemble_instructions`) leaves no slot + // for them via this path. Post-activation re-gating is deferred to the oracle lifecycle + // (see malbeclabs/infra#1700 / doublezero #1699). client.execute_authorized_transaction( DoubleZeroInstruction::UpdateMulticastGroupRoles(UpdateMulticastGroupRolesArgs { publisher: self.publisher, @@ -241,6 +250,8 @@ mod tests { client_ip, publisher: true, subscriber: false, + device_pk: None, + feed_pk: None, } .execute(&client); diff --git a/smartcontract/sdk/rs/src/commands/tenant/delete.rs b/smartcontract/sdk/rs/src/commands/tenant/delete.rs index 017f342b53..9cdfd74b86 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/delete.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/delete.rs @@ -35,7 +35,11 @@ impl DeleteTenantCommand { .collect(); for user_pk in &tenant_users { - DeleteUserCommand { pubkey: *user_pk }.execute(client)?; + DeleteUserCommand { + pubkey: *user_pk, + feed_pk: None, + } + .execute(client)?; } // 2. Clean up access passes before waiting for reference_count to reach 0 diff --git a/smartcontract/sdk/rs/src/commands/user/create_subscribe.rs b/smartcontract/sdk/rs/src/commands/user/create_subscribe.rs index 448528dfc5..bf907f6a49 100644 --- a/smartcontract/sdk/rs/src/commands/user/create_subscribe.rs +++ b/smartcontract/sdk/rs/src/commands/user/create_subscribe.rs @@ -32,6 +32,10 @@ pub struct CreateSubscribeUserCommand { /// Custom owner pubkey (foundation allowlist only). When set, the access pass /// is looked up for this owner instead of the payer. pub owner: Option, + /// Optional trailing Feed account for the EdgeSeat metro gate: the feed (referenced + /// by the pass) covering the device's exchange and listing the target multicast group. + /// Appended to the account list only when provided. + pub feed_pk: Option, } impl CreateSubscribeUserCommand { @@ -115,6 +119,11 @@ impl CreateSubscribeUserCommand { accounts.push(AccountMeta::new(dz_prefix_ext, false)); } + // Optional trailing Feed account (EdgeSeat metro gate). Appended only when provided. + if let Some(feed_pk) = self.feed_pk { + accounts.push(AccountMeta::new_readonly(feed_pk, false)); + } + client .execute_transaction( DoubleZeroInstruction::CreateSubscribeUser(UserCreateSubscribeArgs { @@ -271,6 +280,7 @@ mod tests { subscriber: false, tunnel_endpoint: Ipv4Addr::UNSPECIFIED, owner: None, + feed_pk: None, } .execute(&client); diff --git a/smartcontract/sdk/rs/src/commands/user/delete.rs b/smartcontract/sdk/rs/src/commands/user/delete.rs index 3bb2a13b1f..464820f57c 100644 --- a/smartcontract/sdk/rs/src/commands/user/delete.rs +++ b/smartcontract/sdk/rs/src/commands/user/delete.rs @@ -20,6 +20,20 @@ use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature} #[derive(Debug, PartialEq, Clone)] pub struct DeleteUserCommand { pub pubkey: Pubkey, + /// Reserved for EdgeSeat feed-seat release. Not appended by this builder — the + /// authorized-transaction layout has no slot after the trailing `[payer, system, permission]`; + /// seat release is deferred to the oracle lifecycle (#1699). + pub feed_pk: Option, +} + +impl DeleteUserCommand { + /// Convenience constructor preserving the previous `{ pubkey }` call sites. + pub fn new(pubkey: Pubkey) -> Self { + Self { + pubkey, + feed_pk: None, + } + } } impl DeleteUserCommand { @@ -51,6 +65,8 @@ impl DeleteUserCommand { client_ip: user.client_ip, publisher: false, subscriber: false, + device_pk: None, + feed_pk: None, } .execute(client)?; } @@ -120,6 +136,10 @@ impl DeleteUserCommand { accounts.push(AccountMeta::new(user.owner, false)); + // The on-chain DeleteUser releases the EdgeSeat feed seat from an optional trailing Feed + // account, but the authorized-transaction layout (`[..., payer, system, permission]`) has no + // slot for it here, so this builder does not append one. Seat release is deferred to the + // oracle lifecycle (see malbeclabs/infra#1700 / doublezero #1699). client.execute_authorized_transaction( DoubleZeroInstruction::DeleteUser(UserDeleteArgs { dz_prefix_count: dz_prefix_count_u8, @@ -375,6 +395,7 @@ mod tests { let res = DeleteUserCommand { pubkey: user_pubkey, + feed_pk: None, } .execute(&client); @@ -592,6 +613,7 @@ mod tests { let res = DeleteUserCommand { pubkey: user_pubkey, + feed_pk: None, } .execute(&client); @@ -859,6 +881,7 @@ mod tests { let res = DeleteUserCommand { pubkey: user_pubkey, + feed_pk: None, } .execute(&client); @@ -985,6 +1008,7 @@ mod tests { let res = DeleteUserCommand { pubkey: user_pubkey, + feed_pk: None, } .execute(&client); diff --git a/smartcontract/sdk/rs/src/commands/user/requestban.rs b/smartcontract/sdk/rs/src/commands/user/requestban.rs index bfd5d95613..dacf90be5c 100644 --- a/smartcontract/sdk/rs/src/commands/user/requestban.rs +++ b/smartcontract/sdk/rs/src/commands/user/requestban.rs @@ -50,6 +50,8 @@ impl RequestBanUserCommand { client_ip: user.client_ip, publisher: false, subscriber: false, + device_pk: None, + feed_pk: None, } .execute(client)?; }