diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c2326df..46c392f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,14 @@ All notable changes to this project will be documented in this file. - SDK - revdist Python SDK migrated to the async solana-py RPC API (solana-py 0.40.0 removed the sync `Client`). The `Client` read methods (`fetch_config`, `fetch_distribution`, etc.) are now coroutines and must be awaited; `new_rpc_client` returns an `AsyncClient`. (#3945) +- Serviceability + - The `AccessPass` `EdgeSeat` variant now carries a `Vec` payload (`feed_key` + per-feed cap) instead of being a bare marker. This changes the `AccessPass` borsh layout for EdgeSeat passes. (#1700) ### Added - 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) ### Changes diff --git a/smartcontract/cli/src/accesspass/list.rs b/smartcontract/cli/src/accesspass/list.rs index ca93e7022..1a38cf568 100644 --- a/smartcontract/cli/src/accesspass/list.rs +++ b/smartcontract/cli/src/accesspass/list.rs @@ -141,7 +141,9 @@ fn accesspass_type_short(t: &AccessPassType) -> String { crate::util::abbreviate_prefix(key) ) } - AccessPassType::Prepaid | AccessPassType::EdgeSeat => t.to_string(), + // Compact form: the discriminant only (feed details are shown in the get/JSON views). + AccessPassType::EdgeSeat(_) => t.to_discriminant_string(), + AccessPassType::Prepaid => t.to_string(), } } @@ -212,7 +214,7 @@ impl ListAccessPassCliCommand { // Filter access passes by EdgeSeat type if self.edge_seat { access_passes.retain(|(_, access_pass)| { - matches!(access_pass.accesspass_type, AccessPassType::EdgeSeat) + matches!(access_pass.accesspass_type, AccessPassType::EdgeSeat(_)) }); } // Filter access passes by client IP @@ -640,7 +642,7 @@ mod tests { "prepaid" ); assert_eq!( - super::accesspass_type_short(&AccessPassType::EdgeSeat), + super::accesspass_type_short(&AccessPassType::EdgeSeat(vec![])), "edge_seat" ); // Others: type_name kept, embedded key truncated to a copyable prefix. diff --git a/smartcontract/cli/src/accesspass/set.rs b/smartcontract/cli/src/accesspass/set.rs index 9c24e71c5..4ceeeba63 100644 --- a/smartcontract/cli/src/accesspass/set.rs +++ b/smartcontract/cli/src/accesspass/set.rs @@ -100,7 +100,7 @@ impl SetAccessPassCliCommand { "Others access pass type requires --others-name and --others-key " ), }, - CliAccessPassType::EdgeSeat => AccessPassType::EdgeSeat, + CliAccessPassType::EdgeSeat => AccessPassType::EdgeSeat(vec![]), }; // Convert tenant code to PDA if provided @@ -941,7 +941,7 @@ mod tests { client .expect_set_accesspass() .with(predicate::eq(SetAccessPassCommand { - accesspass_type: AccessPassType::EdgeSeat, + accesspass_type: AccessPassType::EdgeSeat(vec![]), client_ip, user_payer: payer, last_access_epoch: 11, diff --git a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs index 96ffd9e90..2dce8c557 100644 --- a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs @@ -4,7 +4,7 @@ use crate::{ processors::{ accesspass::{ check_status::process_check_status_access_pass, close::process_close_access_pass, - set::process_set_access_pass, + set::process_set_access_pass, set_feeds::process_set_access_pass_feeds, }, allowlist::{ foundation::{ @@ -420,6 +420,9 @@ pub fn process_instruction( DoubleZeroInstruction::DeleteFeed(value) => { process_delete_feed(program_id, accounts, &value)? } + DoubleZeroInstruction::SetAccessPassFeeds(value) => { + process_set_access_pass_feeds(program_id, accounts, &value)? + } }; Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index 1d1342846..b562da609 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -1,6 +1,7 @@ use crate::processors::{ accesspass::{ - check_status::CheckStatusAccessPassArgs, close::CloseAccessPassArgs, set::SetAccessPassArgs, + check_status::CheckStatusAccessPassArgs, close::CloseAccessPassArgs, + set::SetAccessPassArgs, set_feeds::SetAccessPassFeedsArgs, }, allowlist::{ foundation::{add::AddFoundationAllowlistArgs, remove::RemoveFoundationAllowlistArgs}, @@ -246,9 +247,10 @@ pub enum DoubleZeroInstruction { Deprecated111(), // variant 111, (was MigrateDeviceInterfaces) - CreateFeed(FeedCreateArgs), // variant 112 - UpdateFeed(FeedUpdateArgs), // variant 113 - DeleteFeed(FeedDeleteArgs), // variant 114 + CreateFeed(FeedCreateArgs), // variant 112 + UpdateFeed(FeedUpdateArgs), // variant 113 + DeleteFeed(FeedDeleteArgs), // variant 114 + SetAccessPassFeeds(SetAccessPassFeedsArgs), // variant 115 } impl DoubleZeroInstruction { @@ -394,6 +396,7 @@ impl DoubleZeroInstruction { 112 => Ok(Self::CreateFeed(FeedCreateArgs::try_from(rest).unwrap())), 113 => Ok(Self::UpdateFeed(FeedUpdateArgs::try_from(rest).unwrap())), 114 => Ok(Self::DeleteFeed(FeedDeleteArgs::try_from(rest).unwrap())), + 115 => Ok(Self::SetAccessPassFeeds(SetAccessPassFeedsArgs::try_from(rest).unwrap())), _ => Err(ProgramError::InvalidInstructionData), } @@ -542,6 +545,7 @@ impl DoubleZeroInstruction { Self::CreateFeed(_) => "CreateFeed".to_string(), // variant 112 Self::UpdateFeed(_) => "UpdateFeed".to_string(), // variant 113 Self::DeleteFeed(_) => "DeleteFeed".to_string(), // variant 114 + Self::SetAccessPassFeeds(_) => "SetAccessPassFeeds".to_string(), // variant 115 } } @@ -682,6 +686,7 @@ impl DoubleZeroInstruction { Self::CreateFeed(args) => format!("{args:?}"), // variant 112 Self::UpdateFeed(args) => format!("{args:?}"), // variant 113 Self::DeleteFeed(args) => format!("{args:?}"), // variant 114 + Self::SetAccessPassFeeds(args) => format!("{args:?}"), // variant 115 } } } @@ -1377,5 +1382,21 @@ mod tests { DoubleZeroInstruction::DeleteFeed(FeedDeleteArgs {}), "DeleteFeed", ); + test_instruction( + DoubleZeroInstruction::SetAccessPassFeeds(SetAccessPassFeedsArgs { + client_ip: Ipv4Addr::UNSPECIFIED, + user_payer: Pubkey::new_unique(), + feeds: vec![doublezero_serviceability_feed_seat()], + }), + "SetAccessPassFeeds", + ); + } + + fn doublezero_serviceability_feed_seat() -> crate::state::accesspass::FeedSeat { + crate::state::accesspass::FeedSeat { + feed_key: Pubkey::new_unique(), + max_users: 4, + current_users: 0, + } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/mod.rs index 18ec796b7..c16129cc8 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/mod.rs @@ -1,6 +1,7 @@ pub mod check_status; pub mod close; pub mod set; +pub mod set_feeds; use solana_program::{ account_info::AccountInfo, entrypoint::ProgramResult, msg, program::invoke_signed_unchecked, diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set_feeds.rs b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set_feeds.rs new file mode 100644 index 000000000..14ce24529 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set_feeds.rs @@ -0,0 +1,148 @@ +use crate::{ + authorize::authorize, + error::DoubleZeroError, + pda::get_accesspass_pda, + serializer::try_acc_write, + state::{ + accesspass::{AccessPass, AccessPassType, FeedSeat}, + feed::Feed, + globalstate::GlobalState, + permission::permission_flags, + }, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use core::fmt; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, +}; +use std::net::Ipv4Addr; + +/// Provision the feed_keys (SKU seats) onto an EdgeSeat access pass. The provisioning actor is the +/// oracle, authorized via its `ACCESS_PASS_ADMIN` Permission — not the deprecated `feed_authority` +/// slot. `current_users` is preserved for feeds already present on the pass; caps come from the +/// caller (seeded from the coupon). +#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone)] +pub struct SetAccessPassFeedsArgs { + #[incremental(default = Ipv4Addr::UNSPECIFIED)] + pub client_ip: Ipv4Addr, + pub user_payer: Pubkey, + pub feeds: Vec, +} + +impl fmt::Debug for SetAccessPassFeedsArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "client_ip: {}, user_payer: {}, feeds: {}", + self.client_ip, + self.user_payer, + self.feeds.len() + ) + } +} + +pub fn process_set_access_pass_feeds( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &SetAccessPassFeedsArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let accesspass_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + let _system_program = next_account_info(accounts_iter)?; + + // One Feed account per requested seat, in the same order as `value.feeds`. + let mut feed_accounts = Vec::with_capacity(value.feeds.len()); + for _ in 0..value.feeds.len() { + feed_accounts.push(next_account_info(accounts_iter)?); + } + + assert!(payer_account.is_signer, "Payer must be a signer"); + assert_eq!( + accesspass_account.owner, program_id, + "Invalid AccessPass Account Owner" + ); + assert_eq!( + globalstate_account.owner, program_id, + "Invalid GlobalState Account Owner" + ); + assert!( + accesspass_account.is_writable, + "AccessPass Account is not writable" + ); + + let (expected_pda, _) = get_accesspass_pda(program_id, &value.client_ip, &value.user_payer); + assert_eq!( + accesspass_account.key, &expected_pda, + "Invalid AccessPass PubKey" + ); + + // Provisioning actor authorized via ACCESS_PASS_ADMIN (Permission PDA or legacy fallback). + let globalstate = GlobalState::try_from(globalstate_account)?; + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::ACCESS_PASS_ADMIN, + )?; + + let mut accesspass = AccessPass::try_from(accesspass_account)?; + + // Only EdgeSeat passes carry feed seats. Require the pass to already be EdgeSeat (the oracle + // sets the type via SetAccessPass first) so this instruction can't silently convert a pass of + // another type (e.g. SolanaValidator) into an EdgeSeat. + if !matches!(accesspass.accesspass_type, AccessPassType::EdgeSeat(_)) { + msg!("SetAccessPassFeeds requires an EdgeSeat access pass"); + return Err(DoubleZeroError::InvalidArgument.into()); + } + let prior_seats = accesspass.feed_seats().to_vec(); + + // Validate each referenced Feed, preserve live counts, and bump reference_count for + // newly-referenced feeds. NOTE: feeds dropped from the pass are intentionally NOT decremented + // here — their accounts are not passed, and an over-count only makes a feed harder to delete + // (never unsafe), since reference_count solely guards DeleteFeed. + let mut new_seats = Vec::with_capacity(value.feeds.len()); + for (seat, feed_account) in value.feeds.iter().zip(feed_accounts.iter()) { + assert_eq!( + *feed_account.key, seat.feed_key, + "Feed account does not match seat feed_key" + ); + assert_eq!(feed_account.owner, program_id, "Invalid Feed Account Owner"); + let mut feed = Feed::try_from(*feed_account)?; + + let current_users = prior_seats + .iter() + .find(|s| s.feed_key == seat.feed_key) + .map(|s| s.current_users) + .unwrap_or(0); + + if !prior_seats.iter().any(|s| s.feed_key == seat.feed_key) { + assert!(feed_account.is_writable, "Feed Account is not writable"); + feed.reference_count = feed + .reference_count + .checked_add(1) + .ok_or(DoubleZeroError::InvalidIndex)?; + try_acc_write(&feed, feed_account, payer_account, accounts)?; + } + + new_seats.push(FeedSeat { + feed_key: seat.feed_key, + max_users: seat.max_users, + current_users, + }); + } + + accesspass.accesspass_type = AccessPassType::EdgeSeat(new_seats); + try_acc_write(&accesspass, accesspass_account, payer_account, accounts)?; + + msg!("Set {} feed(s) on access pass", value.feeds.len()); + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accesspass.rs b/smartcontract/programs/doublezero-serviceability/src/state/accesspass.rs index 822f5f733..d8530345b 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accesspass.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accesspass.rs @@ -11,6 +11,25 @@ use solana_program::{ }; use std::{fmt, net::Ipv4Addr}; +/// One purchased SKU seat on an EdgeSeat access pass. `feed_key` is the pubkey of the +/// serviceability `Feed` account (the catalog entry); `max_users` is the per-feed concurrent-user +/// cap seeded from the coupon; `current_users` is the live count, ticked at connect and released +/// at disconnect. +#[derive(BorshSerialize, BorshDeserialize, Debug, Default, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FeedSeat { + #[cfg_attr( + feature = "serde", + serde( + serialize_with = "doublezero_program_common::serializer::serialize_pubkey_as_string", + deserialize_with = "doublezero_program_common::serializer::deserialize_pubkey_from_string" + ) + )] + pub feed_key: Pubkey, // 32 + pub max_users: u16, // 2 + pub current_users: u16, // 2 +} + #[repr(u8)] #[derive(BorshSerialize, BorshDeserialize, Debug, Default, Clone, PartialEq)] #[borsh(use_discriminant = true)] @@ -39,8 +58,13 @@ pub enum AccessPassType { Pubkey, ), Others(String, String), // (type_name, key) - // The seat is identified by the access pass `user_payer`, so the variant carries no payload. - EdgeSeat, + /// A metro-gated seat scoped to one or more feeds. Each `FeedSeat` is one SKU (feed_key + + /// per-feed cap). Provisioned by the oracle via `SetAccessPassFeeds`. + /// + /// Layout note (#1700): this variant previously carried no payload (#3865). EdgeSeat is new and + /// has no production passes, so changing the payload (same discriminant index 4) does not + /// orphan any deployed account; no migration is required. + EdgeSeat(Vec), } impl AccessPassType { @@ -50,7 +74,7 @@ impl AccessPassType { AccessPassType::SolanaValidator(_) => "solana_validator".to_string(), AccessPassType::SolanaRPC(_) => "solana_rpc".to_string(), AccessPassType::Others(type_name, _) => type_name.clone(), - AccessPassType::EdgeSeat => "edge_seat".to_string(), + AccessPassType::EdgeSeat(_) => "edge_seat".to_string(), } } } @@ -64,7 +88,7 @@ impl fmt::Display for AccessPassType { AccessPassType::Others(type_name, key) => { write!(f, "others: {} ({})", type_name, key) } - AccessPassType::EdgeSeat => write!(f, "edge_seat"), + AccessPassType::EdgeSeat(seats) => write!(f, "edge_seat: {} feed(s)", seats.len()), } } } @@ -194,7 +218,7 @@ impl fmt::Display for AccessPass { AccessPassType::Others(type_name, details) => { write!(f, "Others: {} ({})", type_name, details) } - AccessPassType::EdgeSeat => write!(f, "EdgeSeat"), + AccessPassType::EdgeSeat(seats) => write!(f, "EdgeSeat: ({} feed(s))", seats.len()), } } } @@ -274,7 +298,7 @@ impl AccessPass { /// 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. pub fn try_add_user(&mut self, user_type: UserType) -> Result<(), DoubleZeroError> { - if !matches!(self.accesspass_type, AccessPassType::EdgeSeat) { + if !matches!(self.accesspass_type, AccessPassType::EdgeSeat(_)) { return Ok(()); } match user_type { @@ -297,7 +321,7 @@ impl AccessPass { /// Release a seat held by a user. EdgeSeat-only: no-op for all other access-pass types. Does NOT /// touch `connection_count`. pub fn remove_user(&mut self, user_type: UserType) { - if !matches!(self.accesspass_type, AccessPassType::EdgeSeat) { + if !matches!(self.accesspass_type, AccessPassType::EdgeSeat(_)) { return; } match user_type { @@ -309,6 +333,14 @@ impl AccessPass { } } } + + /// The feed seats provisioned on this pass (empty for non-EdgeSeat passes). + pub fn feed_seats(&self) -> &[FeedSeat] { + match &self.accesspass_type { + AccessPassType::EdgeSeat(seats) => seats, + _ => &[], + } + } } #[cfg(test)] @@ -339,9 +371,13 @@ mod tests { let b = AccessPassType::SolanaValidator(Pubkey::default()); assert_eq!(object_length(&b).unwrap(), 33); - // EdgeSeat carries no payload: a bare discriminant byte. - let c = AccessPassType::EdgeSeat; - assert_eq!(object_length(&c).unwrap(), 1); + // EdgeSeat: discriminant byte + borsh vec length prefix (4) for an empty seat vec. + let c = AccessPassType::EdgeSeat(vec![]); + assert_eq!(object_length(&c).unwrap(), 5); + + // Each FeedSeat adds 36 bytes (32 pubkey + 2 + 2). + let d = AccessPassType::EdgeSeat(vec![FeedSeat::default()]); + assert_eq!(object_length(&d).unwrap(), 1 + 4 + 36); } #[test] @@ -480,7 +516,7 @@ mod tests { #[test] fn test_edge_seat_user_caps() { - let mut ap = test_accesspass(AccessPassType::EdgeSeat); + let mut ap = test_accesspass(AccessPassType::EdgeSeat(vec![])); // Unicast: cap is 2. ap.try_add_user(UserType::IBRL).unwrap(); diff --git a/smartcontract/programs/doublezero-serviceability/tests/accesspass_test.rs b/smartcontract/programs/doublezero-serviceability/tests/accesspass_test.rs index 92c2c6fc1..6b8f0fff2 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/accesspass_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/accesspass_test.rs @@ -1973,7 +1973,7 @@ async fn test_set_accesspass_refills_depleted_user_payer() { get_accesspass_pda(&program_id, &Ipv4Addr::UNSPECIFIED, &user_payer.pubkey()); let set_access_pass_args = SetAccessPassArgs { - accesspass_type: AccessPassType::EdgeSeat, + accesspass_type: AccessPassType::EdgeSeat(vec![]), client_ip: Ipv4Addr::UNSPECIFIED, last_access_epoch: u64::MAX, allow_multiple_ip: false, 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 e70d0417d..ef903f99a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/delete_user_dynamic_accesspass.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/delete_user_dynamic_accesspass.rs @@ -699,7 +699,7 @@ async fn test_edge_seat_user_caps_enforced() { recent_blockhash, env.program_id, DoubleZeroInstruction::SetAccessPass(SetAccessPassArgs { - accesspass_type: AccessPassType::EdgeSeat, + accesspass_type: AccessPassType::EdgeSeat(vec![]), client_ip: Ipv4Addr::UNSPECIFIED, last_access_epoch: 9999, allow_multiple_ip: false, diff --git a/smartcontract/sdk/rs/src/commands/accesspass/mod.rs b/smartcontract/sdk/rs/src/commands/accesspass/mod.rs index 75d37e346..e13ff5e8c 100644 --- a/smartcontract/sdk/rs/src/commands/accesspass/mod.rs +++ b/smartcontract/sdk/rs/src/commands/accesspass/mod.rs @@ -3,3 +3,4 @@ pub mod close; pub mod get; pub mod list; pub mod set; +pub mod set_feeds; diff --git a/smartcontract/sdk/rs/src/commands/accesspass/set_feeds.rs b/smartcontract/sdk/rs/src/commands/accesspass/set_feeds.rs new file mode 100644 index 000000000..8c6b85e75 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/accesspass/set_feeds.rs @@ -0,0 +1,115 @@ +use std::net::Ipv4Addr; + +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, pda::get_accesspass_pda, + processors::accesspass::set_feeds::SetAccessPassFeedsArgs, state::accesspass::FeedSeat, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; + +/// Provision feed seats (SKUs) onto an EdgeSeat access pass. +/// +/// On-chain account layout (see `process_set_access_pass_feeds`): +/// `[accesspass, globalstate, payer, system, feed_0 .. feed_{N-1}, (optional permission)]` +/// +/// `DoubleZeroClient::execute_transaction` appends `[payer, system]` after the base accounts +/// supplied here, so the base list is `[accesspass, globalstate]` followed by one writable `Feed` +/// account per seat, in the same order as `feeds`. +#[derive(Debug, PartialEq, Clone)] +pub struct SetAccessPassFeedsCommand { + pub client_ip: Ipv4Addr, + pub user_payer: Pubkey, + pub feeds: Vec, +} + +impl SetAccessPassFeedsCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (accesspass_pubkey, _) = + get_accesspass_pda(&client.get_program_id(), &self.client_ip, &self.user_payer); + + let mut accounts = vec![ + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + + // One writable Feed account per seat, in the same order as `feeds`. + for seat in &self.feeds { + accounts.push(AccountMeta::new(seat.feed_key, false)); + } + + client.execute_transaction( + DoubleZeroInstruction::SetAccessPassFeeds(SetAccessPassFeedsArgs { + client_ip: self.client_ip, + user_payer: self.user_payer, + feeds: self.feeds.clone(), + }), + accounts, + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::accesspass::set_feeds::SetAccessPassFeedsCommand, + tests::utils::create_test_client, DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_accesspass_pda, get_globalstate_pda}, + processors::accesspass::set_feeds::SetAccessPassFeedsArgs, + state::accesspass::FeedSeat, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + + #[test] + fn test_commands_set_accesspass_feeds_command() { + let mut client = create_test_client(); + + let client_ip = [10, 0, 0, 1].into(); + let payer = Pubkey::new_unique(); + let feed_key = Pubkey::new_unique(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (accesspass_pubkey, _) = + get_accesspass_pda(&client.get_program_id(), &client_ip, &payer); + + let seats = vec![FeedSeat { + feed_key, + max_users: 5, + current_users: 0, + }]; + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::SetAccessPassFeeds( + SetAccessPassFeedsArgs { + client_ip, + user_payer: payer, + feeds: seats.clone(), + }, + )), + predicate::eq(vec![ + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(feed_key, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = SetAccessPassFeedsCommand { + client_ip, + user_payer: payer, + feeds: seats, + } + .execute(&client); + assert!(res.is_ok()); + } +}