diff --git a/CHANGELOG.md b/CHANGELOG.md index 0622f6f35..e8c2326df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ 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) +### 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) + ### Changes ## [v0.28.0](https://github.com/malbeclabs/doublezero/compare/client/v0.27.1...client/v0.28.0) - 2026-06-26 diff --git a/smartcontract/cli/src/cli/command.rs b/smartcontract/cli/src/cli/command.rs index ec845049c..24b52c451 100644 --- a/smartcontract/cli/src/cli/command.rs +++ b/smartcontract/cli/src/cli/command.rs @@ -20,6 +20,7 @@ use crate::{ contributor::{ContributorCliCommand, ContributorCommands}, device::{DeviceCliCommand, DeviceCommands, InterfaceCommands}, exchange::{ExchangeCliCommand, ExchangeCommands}, + feed::{FeedCliCommand, FeedCommands}, globalconfig::{ AirdropCommands, AuthorityCommands, FeatureFlagsCommands, FoundationAllowlistCommands, GlobalConfigCliCommand, GlobalConfigCommands, QaAllowlistCommands, @@ -62,6 +63,8 @@ pub enum ServiceabilityCommand { Location(LocationCliCommand), /// Manage exchanges Exchange(ExchangeCliCommand), + /// Manage feeds (metro→group-set catalog) + Feed(FeedCliCommand), /// Manage contributors Contributor(ContributorCliCommand), /// Manage permissions @@ -170,6 +173,13 @@ impl ServiceabilityCommand { ExchangeCommands::Get(args) => args.execute(ctx, client, out).await, ExchangeCommands::Delete(args) => args.execute(ctx, client, out).await, }, + Self::Feed(cmd) => match cmd.command { + FeedCommands::Create(args) => args.execute(ctx, client, out).await, + FeedCommands::Update(args) => args.execute(ctx, client, out).await, + FeedCommands::List(args) => args.execute(ctx, client, out).await, + FeedCommands::Get(args) => args.execute(ctx, client, out).await, + FeedCommands::Delete(args) => args.execute(ctx, client, out).await, + }, Self::Contributor(cmd) => match cmd.command { ContributorCommands::Create(args) => args.execute(ctx, client, out).await, ContributorCommands::Update(args) => args.execute(ctx, client, out).await, diff --git a/smartcontract/cli/src/cli/feed.rs b/smartcontract/cli/src/cli/feed.rs new file mode 100644 index 000000000..0bc7dde8a --- /dev/null +++ b/smartcontract/cli/src/cli/feed.rs @@ -0,0 +1,28 @@ +use clap::{Args, Subcommand}; + +use crate::feed::{create::*, delete::*, get::*, list::*, update::*}; + +#[derive(Args, Debug)] +pub struct FeedCliCommand { + #[command(subcommand)] + pub command: FeedCommands, +} + +#[derive(Debug, Subcommand)] +pub enum FeedCommands { + /// Create a new feed (catalog entry) + #[clap()] + Create(CreateFeedCliCommand), + /// Update a feed's name or metro map + #[clap()] + Update(UpdateFeedCliCommand), + /// List all feeds + #[clap()] + List(ListFeedCliCommand), + /// Get details for a specific feed + #[clap()] + Get(GetFeedCliCommand), + /// Delete a feed (must have no references) + #[clap()] + Delete(DeleteFeedCliCommand), +} diff --git a/smartcontract/cli/src/cli/mod.rs b/smartcontract/cli/src/cli/mod.rs index c8ecf4d31..666e659a2 100644 --- a/smartcontract/cli/src/cli/mod.rs +++ b/smartcontract/cli/src/cli/mod.rs @@ -15,6 +15,7 @@ pub use command::ServiceabilityCommand; pub mod contributor; pub mod device; pub mod exchange; +pub mod feed; pub mod globalconfig; pub mod link; pub mod location; diff --git a/smartcontract/cli/src/doublezerocommand.rs b/smartcontract/cli/src/doublezerocommand.rs index 78894668d..4ddb1e37e 100644 --- a/smartcontract/cli/src/doublezerocommand.rs +++ b/smartcontract/cli/src/doublezerocommand.rs @@ -38,6 +38,10 @@ use doublezero_sdk::{ list::ListExchangeCommand, setdevice::SetDeviceExchangeCommand, update::UpdateExchangeCommand, }, + feed::{ + create::CreateFeedCommand, delete::DeleteFeedCommand, get::GetFeedCommand, + list::ListFeedCommand, update::UpdateFeedCommand, + }, globalconfig::set::SetGlobalConfigCommand, globalstate::{ init::InitGlobalStateCommand, setairdrop::SetAirdropCommand, @@ -106,7 +110,7 @@ use doublezero_sdk::{ }, }, telemetry::LinkLatencyStats, - DZClient, DZTransaction, Device, DoubleZeroClient, Exchange, GetGlobalConfigCommand, + DZClient, DZTransaction, Device, DoubleZeroClient, Exchange, Feed, GetGlobalConfigCommand, GetGlobalStateCommand, GlobalConfig, GlobalState, Link, Location, MulticastGroup, ResourceExtensionOwned, TopologyInfo, User, }; @@ -181,6 +185,12 @@ pub trait CliCommand { fn delete_exchange(&self, cmd: DeleteExchangeCommand) -> eyre::Result; fn setdevice_exchange(&self, cmd: SetDeviceExchangeCommand) -> eyre::Result; + fn create_feed(&self, cmd: CreateFeedCommand) -> eyre::Result<(Signature, Pubkey)>; + fn get_feed(&self, cmd: GetFeedCommand) -> eyre::Result<(Pubkey, Feed)>; + fn list_feed(&self, cmd: ListFeedCommand) -> eyre::Result>; + fn update_feed(&self, cmd: UpdateFeedCommand) -> eyre::Result; + fn delete_feed(&self, cmd: DeleteFeedCommand) -> eyre::Result; + fn create_contributor( &self, cmd: CreateContributorCommand, @@ -496,6 +506,21 @@ impl CliCommand for CliCommandImpl<'_> { fn setdevice_exchange(&self, cmd: SetDeviceExchangeCommand) -> eyre::Result { cmd.execute(self.client) } + fn create_feed(&self, cmd: CreateFeedCommand) -> eyre::Result<(Signature, Pubkey)> { + cmd.execute(self.client) + } + fn get_feed(&self, cmd: GetFeedCommand) -> eyre::Result<(Pubkey, Feed)> { + cmd.execute(self.client) + } + fn list_feed(&self, cmd: ListFeedCommand) -> eyre::Result> { + cmd.execute(self.client) + } + fn update_feed(&self, cmd: UpdateFeedCommand) -> eyre::Result { + cmd.execute(self.client) + } + fn delete_feed(&self, cmd: DeleteFeedCommand) -> eyre::Result { + cmd.execute(self.client) + } fn create_contributor( &self, cmd: CreateContributorCommand, diff --git a/smartcontract/cli/src/feed/create.rs b/smartcontract/cli/src/feed/create.rs new file mode 100644 index 000000000..a138ac909 --- /dev/null +++ b/smartcontract/cli/src/feed/create.rs @@ -0,0 +1,42 @@ +use crate::{doublezerocommand::CliCommand, feed::parse_metro, validators::validate_code}; +use clap::Args; +use doublezero_cli_core::{print_signature, require, CliContext, RequirementCheck}; +use doublezero_sdk::commands::feed::create::CreateFeedCommand; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct CreateFeedCliCommand { + /// Unique code for the feed (immutable; used as the PDA seed) + #[arg(long, value_parser = validate_code)] + pub code: String, + /// Human-readable name for the feed + #[arg(long)] + pub name: String, + /// Metro mapping `EXCHANGE_PK=GROUP_PK[,GROUP_PK...]` (repeatable). Omit for a feed with no + /// metro restriction (reachable from any exchange). + #[arg(long = "metro", value_parser = parse_metro)] + pub metros: Vec<(Pubkey, Vec)>, +} + +impl CreateFeedCliCommand { + pub async fn execute( + self, + _ctx: &CliContext, + client: &C, + out: &mut W, + ) -> eyre::Result<()> { + require!( + client, + RequirementCheck::KEYPAIR | RequirementCheck::BALANCE + ); + + let (signature, _pubkey) = client.create_feed(CreateFeedCommand { + code: self.code, + name: self.name, + metros: self.metros, + })?; + + print_signature(out, &signature) + } +} diff --git a/smartcontract/cli/src/feed/delete.rs b/smartcontract/cli/src/feed/delete.rs new file mode 100644 index 000000000..502ede7dd --- /dev/null +++ b/smartcontract/cli/src/feed/delete.rs @@ -0,0 +1,33 @@ +use crate::{doublezerocommand::CliCommand, validators::validate_pubkey_or_code}; +use clap::Args; +use doublezero_cli_core::{print_signature, require, CliContext, RequirementCheck}; +use doublezero_sdk::commands::feed::{delete::DeleteFeedCommand, get::GetFeedCommand}; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct DeleteFeedCliCommand { + /// Feed pubkey or code to delete + #[arg(long, value_parser = validate_pubkey_or_code)] + pub pubkey: String, +} + +impl DeleteFeedCliCommand { + pub async fn execute( + self, + _ctx: &CliContext, + client: &C, + out: &mut W, + ) -> eyre::Result<()> { + require!( + client, + RequirementCheck::KEYPAIR | RequirementCheck::BALANCE + ); + + let (pubkey, _feed) = client.get_feed(GetFeedCommand { + pubkey_or_code: self.pubkey, + })?; + + let signature = client.delete_feed(DeleteFeedCommand { pubkey })?; + print_signature(out, &signature) + } +} diff --git a/smartcontract/cli/src/feed/get.rs b/smartcontract/cli/src/feed/get.rs new file mode 100644 index 000000000..f8dd453f8 --- /dev/null +++ b/smartcontract/cli/src/feed/get.rs @@ -0,0 +1,52 @@ +use crate::{doublezerocommand::CliCommand, validators::validate_pubkey_or_code}; +use clap::Args; +use doublezero_cli_core::{render_record, CliContext, OutputFormat}; +use doublezero_sdk::commands::feed::get::GetFeedCommand; +use serde::Serialize; +use std::io::Write; +use tabled::Tabled; + +#[derive(Args, Debug)] +pub struct GetFeedCliCommand { + /// Feed pubkey or code to get details for + #[arg(long, value_parser = validate_pubkey_or_code)] + pub pubkey: String, + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Tabled, Serialize)] +struct FeedDisplay { + pub account: String, + pub code: String, + pub name: String, + /// Number of metros (exchanges) in the feed map. Empty ⇒ no metro restriction. + pub metros: usize, + pub reference_count: u32, + pub owner: String, +} + +impl GetFeedCliCommand { + pub async fn execute( + self, + _ctx: &CliContext, + client: &C, + out: &mut W, + ) -> eyre::Result<()> { + let (pubkey, feed) = client.get_feed(GetFeedCommand { + pubkey_or_code: self.pubkey, + })?; + + let display = FeedDisplay { + account: pubkey.to_string(), + code: feed.code, + name: feed.name, + metros: feed.metros.len(), + reference_count: feed.reference_count, + owner: feed.owner.to_string(), + }; + + render_record(out, &display, OutputFormat::from_flags(self.json, false)) + } +} diff --git a/smartcontract/cli/src/feed/list.rs b/smartcontract/cli/src/feed/list.rs new file mode 100644 index 000000000..0b041a69e --- /dev/null +++ b/smartcontract/cli/src/feed/list.rs @@ -0,0 +1,62 @@ +use crate::doublezerocommand::CliCommand; +use clap::Args; +use doublezero_cli_core::{render_collection, CliContext, OutputFormat}; +use doublezero_program_common::serializer; +use doublezero_sdk::commands::feed::list::ListFeedCommand; +use serde::Serialize; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; +use tabled::Tabled; + +#[derive(Args, Debug)] +pub struct ListFeedCliCommand { + /// Output in JSON format + #[arg(long, default_value_t = false)] + pub json: bool, + /// Output in compact JSON format + #[arg(long, default_value_t = false)] + pub json_compact: bool, +} + +#[derive(Tabled, Serialize)] +pub struct FeedDisplay { + #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] + pub account: Pubkey, + pub code: String, + pub name: String, + pub metros: usize, + pub reference_count: u32, + #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] + pub owner: Pubkey, +} + +impl ListFeedCliCommand { + pub async fn execute( + self, + _ctx: &CliContext, + client: &C, + out: &mut W, + ) -> eyre::Result<()> { + let feeds = client.list_feed(ListFeedCommand)?; + + let mut displays: Vec = feeds + .into_iter() + .map(|(pubkey, feed)| FeedDisplay { + account: pubkey, + code: feed.code, + name: feed.name, + metros: feed.metros.len(), + reference_count: feed.reference_count, + owner: feed.owner, + }) + .collect(); + + displays.sort_by(|a, b| a.code.cmp(&b.code)); + + render_collection( + out, + displays, + OutputFormat::from_flags(self.json, self.json_compact), + ) + } +} diff --git a/smartcontract/cli/src/feed/mod.rs b/smartcontract/cli/src/feed/mod.rs new file mode 100644 index 000000000..b0edab372 --- /dev/null +++ b/smartcontract/cli/src/feed/mod.rs @@ -0,0 +1,49 @@ +pub mod create; +pub mod delete; +pub mod get; +pub mod list; +pub mod update; + +use solana_sdk::pubkey::Pubkey; +use std::str::FromStr; + +/// Parse a `--metro` argument of the form `EXCHANGE_PK=GROUP_PK[,GROUP_PK...]` into +/// `(exchange_pk, [group_pk, ...])`. An empty group list (`EXCHANGE_PK=`) is allowed. +pub fn parse_metro(s: &str) -> Result<(Pubkey, Vec), String> { + let (exchange, groups) = s + .split_once('=') + .ok_or_else(|| format!("expected EXCHANGE_PK=GROUP_PK[,GROUP_PK...], got '{s}'"))?; + let exchange_pk = + Pubkey::from_str(exchange.trim()).map_err(|e| format!("invalid exchange pubkey: {e}"))?; + let group_pks = groups + .split(',') + .map(str::trim) + .filter(|g| !g.is_empty()) + .map(|g| Pubkey::from_str(g).map_err(|e| format!("invalid group pubkey '{g}': {e}"))) + .collect::, _>>()?; + Ok((exchange_pk, group_pks)) +} + +#[cfg(test)] +mod tests { + use super::parse_metro; + use solana_sdk::pubkey::Pubkey; + + #[test] + fn test_parse_metro() { + let ex = Pubkey::new_unique(); + let g1 = Pubkey::new_unique(); + let g2 = Pubkey::new_unique(); + let (e, gs) = parse_metro(&format!("{ex}={g1},{g2}")).unwrap(); + assert_eq!(e, ex); + assert_eq!(gs, vec![g1, g2]); + + // No groups is allowed. + let (e, gs) = parse_metro(&format!("{ex}=")).unwrap(); + assert_eq!(e, ex); + assert!(gs.is_empty()); + + assert!(parse_metro("not-a-pair").is_err()); + assert!(parse_metro("bad=alsoBad").is_err()); + } +} diff --git a/smartcontract/cli/src/feed/update.rs b/smartcontract/cli/src/feed/update.rs new file mode 100644 index 000000000..0c1125afc --- /dev/null +++ b/smartcontract/cli/src/feed/update.rs @@ -0,0 +1,55 @@ +use crate::{ + doublezerocommand::CliCommand, feed::parse_metro, validators::validate_pubkey_or_code, +}; +use clap::Args; +use doublezero_cli_core::{print_signature, require, CliContext, RequirementCheck}; +use doublezero_sdk::commands::feed::{get::GetFeedCommand, update::UpdateFeedCommand}; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct UpdateFeedCliCommand { + /// Feed pubkey or code to update + #[arg(long, value_parser = validate_pubkey_or_code)] + pub pubkey: String, + /// Updated name for the feed + #[arg(long)] + pub name: Option, + /// Replace the metro map with these `EXCHANGE_PK=GROUP_PK[,GROUP_PK...]` entries (repeatable). + /// When omitted, the metro map is left unchanged. + #[arg(long = "metro", value_parser = parse_metro)] + pub metros: Vec<(Pubkey, Vec)>, +} + +impl UpdateFeedCliCommand { + pub async fn execute( + self, + _ctx: &CliContext, + client: &C, + out: &mut W, + ) -> eyre::Result<()> { + require!( + client, + RequirementCheck::KEYPAIR | RequirementCheck::BALANCE + ); + + let (pubkey, _feed) = client.get_feed(GetFeedCommand { + pubkey_or_code: self.pubkey, + })?; + + // An empty `--metro` list leaves the map unchanged; pass Some only when entries are given. + let metros = if self.metros.is_empty() { + None + } else { + Some(self.metros) + }; + + let signature = client.update_feed(UpdateFeedCommand { + pubkey, + name: self.name, + metros, + })?; + + print_signature(out, &signature) + } +} diff --git a/smartcontract/cli/src/lib.rs b/smartcontract/cli/src/lib.rs index 534b896d5..9f18752d4 100644 --- a/smartcontract/cli/src/lib.rs +++ b/smartcontract/cli/src/lib.rs @@ -12,6 +12,7 @@ pub mod device; pub mod doublezerocommand; pub mod exchange; pub mod export; +pub mod feed; pub mod formatters; pub mod globalconfig; pub mod helpers; diff --git a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs index 616171a1a..96ffd9e90 100644 --- a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs @@ -36,6 +36,9 @@ use crate::{ resume::process_resume_exchange, setdevice::process_setdevice_exchange, suspend::process_suspend_exchange, update::process_update_exchange, }, + feed::{ + create::process_create_feed, delete::process_delete_feed, update::process_update_feed, + }, globalconfig::set::process_set_globalconfig, globalstate::{ initialize::initialize_global_state, setairdrop::process_set_airdrop, @@ -408,6 +411,15 @@ pub fn process_instruction( process_assign_topology_node_segments(program_id, accounts, &value)? } DoubleZeroInstruction::Deprecated111() => (), + DoubleZeroInstruction::CreateFeed(value) => { + process_create_feed(program_id, accounts, &value)? + } + DoubleZeroInstruction::UpdateFeed(value) => { + process_update_feed(program_id, accounts, &value)? + } + DoubleZeroInstruction::DeleteFeed(value) => { + process_delete_feed(program_id, accounts, &value)? + } }; Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index 3aa763d67..1d1342846 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -25,6 +25,7 @@ use crate::processors::{ create::ExchangeCreateArgs, delete::ExchangeDeleteArgs, resume::ExchangeResumeArgs, setdevice::ExchangeSetDeviceArgs, suspend::ExchangeSuspendArgs, update::ExchangeUpdateArgs, }, + feed::{create::FeedCreateArgs, delete::FeedDeleteArgs, update::FeedUpdateArgs}, globalconfig::set::SetGlobalConfigArgs, globalstate::{ setairdrop::SetAirdropArgs, setauthority::SetAuthorityArgs, @@ -244,6 +245,10 @@ pub enum DoubleZeroInstruction { AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs), // variant 110 Deprecated111(), // variant 111, (was MigrateDeviceInterfaces) + + CreateFeed(FeedCreateArgs), // variant 112 + UpdateFeed(FeedUpdateArgs), // variant 113 + DeleteFeed(FeedDeleteArgs), // variant 114 } impl DoubleZeroInstruction { @@ -386,6 +391,10 @@ impl DoubleZeroInstruction { 110 => Ok(Self::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs::try_from(rest).unwrap())), 111 => Ok(Self::Deprecated111()), + 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())), + _ => Err(ProgramError::InvalidInstructionData), } } @@ -529,6 +538,10 @@ impl DoubleZeroInstruction { Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 Self::AssignTopologyNodeSegments(_) => "AssignTopologyNodeSegments".to_string(), // variant 110 Self::Deprecated111() => "Deprecated111".to_string(), // variant 111 + + Self::CreateFeed(_) => "CreateFeed".to_string(), // variant 112 + Self::UpdateFeed(_) => "UpdateFeed".to_string(), // variant 113 + Self::DeleteFeed(_) => "DeleteFeed".to_string(), // variant 114 } } @@ -665,6 +678,10 @@ impl DoubleZeroInstruction { Self::ClearTopology(args) => format!("{args:?}"), // variant 109 Self::AssignTopologyNodeSegments(args) => format!("{args:?}"), // variant 110 Self::Deprecated111() => String::new(), // variant 111 + + Self::CreateFeed(args) => format!("{args:?}"), // variant 112 + Self::UpdateFeed(args) => format!("{args:?}"), // variant 113 + Self::DeleteFeed(args) => format!("{args:?}"), // variant 114 } } } @@ -1341,5 +1358,24 @@ mod tests { }), "AssignTopologyNodeSegments", ); + test_instruction( + DoubleZeroInstruction::CreateFeed(FeedCreateArgs { + code: "shreds".to_string(), + name: "Shreds".to_string(), + metros: vec![(Pubkey::new_unique(), vec![Pubkey::new_unique()])], + }), + "CreateFeed", + ); + test_instruction( + DoubleZeroInstruction::UpdateFeed(FeedUpdateArgs { + name: Some("Shreds".to_string()), + metros: Some(vec![(Pubkey::new_unique(), vec![Pubkey::new_unique()])]), + }), + "UpdateFeed", + ); + test_instruction( + DoubleZeroInstruction::DeleteFeed(FeedDeleteArgs {}), + "DeleteFeed", + ); } } diff --git a/smartcontract/programs/doublezero-serviceability/src/pda.rs b/smartcontract/programs/doublezero-serviceability/src/pda.rs index 68e198268..d3adc1cd6 100644 --- a/smartcontract/programs/doublezero-serviceability/src/pda.rs +++ b/smartcontract/programs/doublezero-serviceability/src/pda.rs @@ -5,7 +5,7 @@ use solana_program::pubkey::Pubkey; use crate::{ seeds::{ SEED_ACCESS_PASS, SEED_ADMIN_GROUP_BITS, SEED_CONFIG, SEED_CONTRIBUTOR, SEED_DEVICE, - SEED_DEVICE_TUNNEL_BLOCK, SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, + SEED_DEVICE_TUNNEL_BLOCK, SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_FEED, SEED_GLOBALSTATE, SEED_INDEX, SEED_LINK, SEED_LINK_IDS, SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP, SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PERMISSION, SEED_PREFIX, SEED_PROGRAM_CONFIG, SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TOPOLOGY, SEED_TUNNEL_IDS, @@ -103,6 +103,12 @@ pub fn get_accesspass_pda( ) } +/// A Feed PDA is seeded by its human `code`, so the code is immutable (it is the key) and +/// `feed_key` == this PDA. No global index is consumed. +pub fn get_feed_pda(program_id: &Pubkey, code: &str) -> (Pubkey, u8) { + Pubkey::find_program_address(&[SEED_PREFIX, SEED_FEED, code.as_bytes()], program_id) +} + pub fn get_topology_pda(program_id: &Pubkey, name: &str) -> (Pubkey, u8) { let upper = name.to_ascii_uppercase(); Pubkey::find_program_address(&[SEED_PREFIX, SEED_TOPOLOGY, upper.as_bytes()], program_id) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs new file mode 100644 index 000000000..9d9ae2e37 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs @@ -0,0 +1,107 @@ +use crate::{ + authorize::authorize, + error::DoubleZeroError, + pda::get_feed_pda, + seeds::{SEED_FEED, SEED_PREFIX}, + serializer::try_acc_create, + state::{ + accounttype::AccountType, feed::Feed, globalstate::GlobalState, + permission::permission_flags, + }, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use core::fmt; +use doublezero_program_common::validate_account_code; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + program_error::ProgramError, + pubkey::Pubkey, +}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone, Default)] +pub struct FeedCreateArgs { + pub code: String, + pub name: String, + /// `exchange_pk → group_pks`. Empty ⇒ no metro restriction. + pub metros: Vec<(Pubkey, Vec)>, +} + +impl fmt::Debug for FeedCreateArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "code: {}, name: {}, metros: {}", + self.code, + self.name, + self.metros.len() + ) + } +} + +pub fn process_create_feed( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &FeedCreateArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let feed_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)?; + + assert!(payer_account.is_signer, "Payer must be a signer"); + assert_eq!( + globalstate_account.owner, program_id, + "Invalid GlobalState Account Owner" + ); + assert_eq!( + *system_program.unsigned_key(), + solana_system_interface::program::ID, + "Invalid System Program Account Owner" + ); + assert!(feed_account.is_writable, "PDA Account is not writable"); + + let code = + validate_account_code(&value.code).map_err(|_| DoubleZeroError::InvalidAccountCode)?; + + let (expected_pda, bump_seed) = get_feed_pda(program_id, &code); + assert_eq!(feed_account.key, &expected_pda, "Invalid Feed PubKey"); + + if !feed_account.data_is_empty() { + return Err(ProgramError::AccountAlreadyInitialized); + } + + // Catalog admin: FEED_AUTHORITY (Permission PDA) or FOUNDATION. + let globalstate = GlobalState::try_from(globalstate_account)?; + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::FEED_AUTHORITY | permission_flags::FOUNDATION, + )?; + + let feed = Feed { + account_type: AccountType::Feed, + owner: *payer_account.key, + bump_seed, + code: code.clone(), + name: value.name.clone(), + reference_count: 0, + metros: value.metros.clone(), + }; + + try_acc_create( + &feed, + feed_account, + payer_account, + system_program, + program_id, + &[SEED_PREFIX, SEED_FEED, code.as_bytes(), &[bump_seed]], + )?; + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/delete.rs new file mode 100644 index 000000000..83e44c1af --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/delete.rs @@ -0,0 +1,62 @@ +use crate::{ + authorize::authorize, + error::DoubleZeroError, + serializer::try_acc_close, + state::{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, + pubkey::Pubkey, +}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone, Default)] +pub struct FeedDeleteArgs {} + +impl fmt::Debug for FeedDeleteArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "") + } +} + +pub fn process_delete_feed( + program_id: &Pubkey, + accounts: &[AccountInfo], + _value: &FeedDeleteArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let feed_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)?; + + assert!(payer_account.is_signer, "Payer must be a signer"); + assert_eq!(feed_account.owner, program_id, "Invalid PDA Account Owner"); + assert_eq!( + globalstate_account.owner, program_id, + "Invalid GlobalState Account Owner" + ); + assert!(feed_account.is_writable, "PDA Account is not writable"); + + let globalstate = GlobalState::try_from(globalstate_account)?; + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::FEED_AUTHORITY | permission_flags::FOUNDATION, + )?; + + let feed = Feed::try_from(feed_account)?; + if feed.reference_count > 0 { + return Err(DoubleZeroError::ReferenceCountNotZero.into()); + } + + try_acc_close(feed_account, payer_account)?; + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs new file mode 100644 index 000000000..fdb2f5561 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs @@ -0,0 +1,3 @@ +pub mod create; +pub mod delete; +pub mod update; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/update.rs new file mode 100644 index 000000000..46ebe50b6 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/update.rs @@ -0,0 +1,73 @@ +use crate::{ + authorize::authorize, + serializer::try_acc_write, + state::{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, + pubkey::Pubkey, +}; + +/// `code` is the PDA seed and therefore immutable; only `name` and the metro map are mutable. +#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone, Default)] +pub struct FeedUpdateArgs { + pub name: Option, + pub metros: Option)>>, +} + +impl fmt::Debug for FeedUpdateArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "name: {:?}, metros: {:?}", + self.name, + self.metros.as_ref().map(|m| m.len()) + ) + } +} + +pub fn process_update_feed( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &FeedUpdateArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let feed_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)?; + + assert!(payer_account.is_signer, "Payer must be a signer"); + assert_eq!(feed_account.owner, program_id, "Invalid PDA Account Owner"); + assert_eq!( + globalstate_account.owner, program_id, + "Invalid GlobalState Account Owner" + ); + assert!(feed_account.is_writable, "PDA Account is not writable"); + + let globalstate = GlobalState::try_from(globalstate_account)?; + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::FEED_AUTHORITY | permission_flags::FOUNDATION, + )?; + + let mut feed = Feed::try_from(feed_account)?; + if let Some(ref name) = value.name { + feed.name = name.clone(); + } + if let Some(ref metros) = value.metros { + feed.metros = metros.clone(); + } + + try_acc_write(&feed, feed_account, payer_account, accounts)?; + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs index 4a4d4ec73..e02c03b93 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs @@ -3,6 +3,7 @@ pub mod allowlist; pub mod contributor; pub mod device; pub mod exchange; +pub mod feed; pub mod globalconfig; pub mod globalstate; pub mod index; diff --git a/smartcontract/programs/doublezero-serviceability/src/seeds.rs b/smartcontract/programs/doublezero-serviceability/src/seeds.rs index d4d98eae6..9dabbb0a3 100644 --- a/smartcontract/programs/doublezero-serviceability/src/seeds.rs +++ b/smartcontract/programs/doublezero-serviceability/src/seeds.rs @@ -24,3 +24,4 @@ pub const SEED_PERMISSION: &[u8] = b"permission"; pub const SEED_ADMIN_GROUP_BITS: &[u8] = b"admingroupbits"; pub const SEED_INDEX: &[u8] = b"index"; pub const SEED_TOPOLOGY: &[u8] = b"topology"; +pub const SEED_FEED: &[u8] = b"feed"; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs index 58c6e4c54..4254a6683 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs @@ -2,10 +2,11 @@ use crate::{ error::DoubleZeroError, state::{ accesspass::AccessPass, accounttype::AccountType, contributor::Contributor, device::Device, - exchange::Exchange, globalconfig::GlobalConfig, globalstate::GlobalState, index::Index, - link::Link, location::Location, multicastgroup::MulticastGroup, permission::Permission, - programconfig::ProgramConfig, resource_extension::ResourceExtensionOwned, tenant::Tenant, - topology::TopologyInfo, user::User, + exchange::Exchange, feed::Feed, globalconfig::GlobalConfig, globalstate::GlobalState, + index::Index, link::Link, location::Location, multicastgroup::MulticastGroup, + permission::Permission, programconfig::ProgramConfig, + resource_extension::ResourceExtensionOwned, tenant::Tenant, topology::TopologyInfo, + user::User, }, }; use solana_program::program_error::ProgramError; @@ -31,6 +32,7 @@ pub enum AccountData { Permission(Permission), Index(Index), Topology(TopologyInfo), + Feed(Feed), } impl AccountData { @@ -53,6 +55,7 @@ impl AccountData { AccountData::Permission(_) => "Permission", AccountData::Index(_) => "Index", AccountData::Topology(_) => "Topology", + AccountData::Feed(_) => "Feed", } } @@ -75,6 +78,7 @@ impl AccountData { AccountData::Permission(permission) => permission.to_string(), AccountData::Index(index) => index.to_string(), AccountData::Topology(topology) => topology.to_string(), + AccountData::Feed(feed) => feed.to_string(), } } @@ -205,6 +209,14 @@ impl AccountData { Err(DoubleZeroError::InvalidAccountType) } } + + pub fn get_feed(&self) -> Result { + if let AccountData::Feed(feed) = self { + Ok(feed.clone()) + } else { + Err(DoubleZeroError::InvalidAccountType) + } + } } impl TryFrom<&[u8]> for AccountData { @@ -250,6 +262,7 @@ impl TryFrom<&[u8]> for AccountData { AccountType::Topology => Ok(AccountData::Topology(TopologyInfo::try_from( bytes as &[u8], )?)), + AccountType::Feed => Ok(AccountData::Feed(Feed::try_from(bytes as &[u8])?)), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs b/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs index a4e8501aa..80b9fd157 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs @@ -25,6 +25,7 @@ pub enum AccountType { Permission = 15, Index = 16, Topology = 17, + Feed = 18, } pub trait AccountTypeInfo { @@ -54,6 +55,7 @@ impl From for AccountType { 15 => AccountType::Permission, 16 => AccountType::Index, 17 => AccountType::Topology, + 18 => AccountType::Feed, _ => AccountType::None, } } @@ -79,6 +81,7 @@ impl fmt::Display for AccountType { AccountType::Permission => write!(f, "permission"), AccountType::Index => write!(f, "index"), AccountType::Topology => write!(f, "topology"), + AccountType::Feed => write!(f, "feed"), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/feed.rs b/smartcontract/programs/doublezero-serviceability/src/state/feed.rs new file mode 100644 index 000000000..6ad0c2114 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/state/feed.rs @@ -0,0 +1,183 @@ +use crate::{ + error::{DoubleZeroError, Validate}, + state::accounttype::AccountType, +}; +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_program::{account_info::AccountInfo, msg, program_error::ProgramError, pubkey::Pubkey}; +use std::fmt; + +/// Result of matching a device's exchange (metro) against a [`Feed`]'s metro map. +#[derive(Debug, PartialEq)] +pub enum FeedMetroMatch<'a> { + /// The feed has no metros: it imposes no metro restriction and is reachable from any + /// exchange (and admits any group). + Unrestricted, + /// The exchange is covered; these are the joinable multicast groups for it. + Groups(&'a [Pubkey]), + /// The feed has metros but none match the exchange. + NotCovered, +} + +/// A serviceability catalog entry: the `metro(exchange) → group-set` map for one SKU. +/// +/// The pubkey of this account (`feed_key`) is the SKU identifier carried on EdgeSeat access +/// passes. `code` is the PDA seed, so it is immutable; `name` and `metros` are mutable. +/// A feed with an empty `metros` vec imposes no metro restriction (reachable from anywhere). +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Feed { + pub account_type: AccountType, // 1 + #[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 owner: Pubkey, // 32 + pub bump_seed: u8, // 1 + pub code: String, // 4 + len (PDA seed, immutable) + pub name: String, // 4 + len + pub reference_count: u32, // 4 - number of access passes referencing this feed + /// `exchange_pk → group_pks`. Empty ⇒ no metro restriction. + pub metros: Vec<(Pubkey, Vec)>, +} + +impl Feed { + /// Match `exchange` against this feed's metro map. See [`FeedMetroMatch`]. + pub fn groups_for(&self, exchange: &Pubkey) -> FeedMetroMatch<'_> { + if self.metros.is_empty() { + return FeedMetroMatch::Unrestricted; + } + match self.metros.iter().find(|(ex, _)| ex == exchange) { + Some((_, groups)) => FeedMetroMatch::Groups(groups), + None => FeedMetroMatch::NotCovered, + } + } +} + +impl fmt::Display for Feed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "account_type: {}, owner: {}, bump_seed: {}, code: {}, name: {}, reference_count: {}, metros: {}", + self.account_type, + self.owner, + self.bump_seed, + self.code, + self.name, + self.reference_count, + self.metros.len() + ) + } +} + +impl TryFrom<&[u8]> for Feed { + type Error = ProgramError; + + fn try_from(mut data: &[u8]) -> Result { + let out = Self { + account_type: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + owner: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + bump_seed: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + code: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + name: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + reference_count: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + metros: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + }; + + if out.account_type != AccountType::Feed { + return Err(ProgramError::InvalidAccountData); + } + + Ok(out) + } +} + +impl TryFrom<&AccountInfo<'_>> for Feed { + type Error = ProgramError; + + fn try_from(account: &AccountInfo) -> Result { + let data = account.try_borrow_data()?; + let res = Self::try_from(&data[..]); + if res.is_err() { + msg!("Failed to deserialize Feed: {:?}", res.as_ref().err()); + } + res + } +} + +impl Validate for Feed { + fn validate(&self) -> Result<(), DoubleZeroError> { + if self.account_type != AccountType::Feed { + msg!("Invalid account type: {}", self.account_type); + return Err(DoubleZeroError::InvalidAccountType); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn feed_with(metros: Vec<(Pubkey, Vec)>) -> Feed { + Feed { + account_type: AccountType::Feed, + owner: Pubkey::new_unique(), + bump_seed: 1, + code: "shreds".to_string(), + name: "Shreds".to_string(), + reference_count: 0, + metros, + } + } + + #[test] + fn test_feed_serialization_roundtrip() { + let val = feed_with(vec![( + Pubkey::new_unique(), + vec![Pubkey::new_unique(), Pubkey::new_unique()], + )]); + let data = borsh::to_vec(&val).unwrap(); + let val2 = Feed::try_from(&data[..]).unwrap(); + val.validate().unwrap(); + val2.validate().unwrap(); + assert_eq!(val, val2); + assert_eq!(data.len(), borsh::object_length(&val).unwrap()); + } + + #[test] + fn test_groups_for_empty_metros_is_unrestricted() { + let feed = feed_with(vec![]); + assert_eq!( + feed.groups_for(&Pubkey::new_unique()), + FeedMetroMatch::Unrestricted + ); + } + + #[test] + fn test_groups_for_covered_and_not_covered() { + let fra = Pubkey::new_unique(); + let g1 = Pubkey::new_unique(); + let g2 = Pubkey::new_unique(); + let feed = feed_with(vec![(fra, vec![g1, g2])]); + + match feed.groups_for(&fra) { + FeedMetroMatch::Groups(groups) => assert_eq!(groups, &[g1, g2]), + other => panic!("expected Groups, got {other:?}"), + } + assert_eq!( + feed.groups_for(&Pubkey::new_unique()), + FeedMetroMatch::NotCovered + ); + } + + #[test] + fn test_feed_wrong_account_type_rejected() { + let mut val = feed_with(vec![]); + val.account_type = AccountType::Exchange; + let data = borsh::to_vec(&val).unwrap(); + assert!(Feed::try_from(&data[..]).is_err()); + } +} diff --git a/smartcontract/programs/doublezero-serviceability/src/state/mod.rs b/smartcontract/programs/doublezero-serviceability/src/state/mod.rs index dda96c197..809abec3f 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/mod.rs @@ -5,6 +5,7 @@ pub mod contributor; pub mod device; pub mod exchange; pub mod feature_flags; +pub mod feed; pub mod globalconfig; pub mod globalstate; pub mod index; diff --git a/smartcontract/sdk/rs/src/commands/feed/create.rs b/smartcontract/sdk/rs/src/commands/feed/create.rs new file mode 100644 index 000000000..0477ed2a6 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/feed/create.rs @@ -0,0 +1,99 @@ +use doublezero_program_common::validate_account_code; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, pda::get_feed_pda, + processors::feed::create::FeedCreateArgs, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; + +#[derive(Debug, PartialEq, Clone)] +pub struct CreateFeedCommand { + pub code: String, + pub name: String, + /// `exchange_pk → group_pks`. Empty ⇒ no metro restriction. + pub metros: Vec<(Pubkey, Vec)>, +} + +impl CreateFeedCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result<(Signature, Pubkey)> { + let code = + validate_account_code(&self.code).map_err(|err| eyre::eyre!("invalid code: {err}"))?; + + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (pda_pubkey, _) = get_feed_pda(&client.get_program_id(), &code); + + // Accounts: [feed, globalstate, (payer, system appended by client)]. + client + .execute_transaction( + DoubleZeroInstruction::CreateFeed(FeedCreateArgs { + code, + name: self.name.clone(), + metros: self.metros.clone(), + }), + vec![ + AccountMeta::new(pda_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + ) + .map(|sig| (sig, pda_pubkey)) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::feed::create::CreateFeedCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_feed_pda, get_globalstate_pda}, + processors::feed::create::FeedCreateArgs, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, signature::Signature}; + + #[test] + fn test_commands_feed_create_command() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _globalstate) = get_globalstate_pda(&client.get_program_id()); + let (pda_pubkey, _) = get_feed_pda(&client.get_program_id(), "test_feed"); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::CreateFeed(FeedCreateArgs { + code: "test_feed".to_string(), + name: "Test Feed".to_string(), + metros: vec![], + })), + predicate::eq(vec![ + AccountMeta::new(pda_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let create_command = CreateFeedCommand { + code: "test_feed".to_string(), + name: "Test Feed".to_string(), + metros: vec![], + }; + + let create_invalid_command = CreateFeedCommand { + code: "test/feed".to_string(), + ..create_command.clone() + }; + + let res = create_command.execute(&client); + assert!(res.is_ok()); + + let res = create_invalid_command.execute(&client); + assert!(res.is_err()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/feed/delete.rs b/smartcontract/sdk/rs/src/commands/feed/delete.rs new file mode 100644 index 000000000..f92e53c6d --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/feed/delete.rs @@ -0,0 +1,96 @@ +use crate::{ + commands::{feed::get::GetFeedCommand, globalstate::get::GetGlobalStateCommand}, + DoubleZeroClient, +}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, processors::feed::delete::FeedDeleteArgs, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct DeleteFeedCommand { + pub pubkey: Pubkey, +} + +impl DeleteFeedCommand { + 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 (_, feed) = GetFeedCommand { + pubkey_or_code: self.pubkey.to_string(), + } + .execute(client) + .map_err(|_err| eyre::eyre!("Feed not found"))?; + + if feed.reference_count > 0 { + return Err(eyre::eyre!( + "Feed cannot be deleted, it has {} references", + feed.reference_count + )); + } + + // Accounts: [feed, globalstate, (payer, system appended by client)]. + client.execute_transaction( + DoubleZeroInstruction::DeleteFeed(FeedDeleteArgs {}), + vec![ + AccountMeta::new(self.pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::feed::delete::DeleteFeedCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_feed_pda, get_globalstate_pda}, + processors::feed::delete::FeedDeleteArgs, + state::{accountdata::AccountData, accounttype::AccountType, feed::Feed}, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + + #[test] + fn test_commands_feed_delete_command() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _globalstate) = get_globalstate_pda(&client.get_program_id()); + let (pda_pubkey, _) = get_feed_pda(&client.get_program_id(), "test_feed"); + let feed = Feed { + account_type: AccountType::Feed, + owner: Pubkey::default(), + bump_seed: 255, + code: "test_feed".to_string(), + name: "Test Feed".to_string(), + reference_count: 0, + metros: vec![], + }; + + client + .expect_get() + .with(predicate::eq(pda_pubkey)) + .returning(move |_| Ok(AccountData::Feed(feed.clone()))); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::DeleteFeed(FeedDeleteArgs {})), + predicate::eq(vec![ + AccountMeta::new(pda_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = DeleteFeedCommand { pubkey: pda_pubkey }.execute(&client); + + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/feed/get.rs b/smartcontract/sdk/rs/src/commands/feed/get.rs new file mode 100644 index 000000000..a452b2e19 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/feed/get.rs @@ -0,0 +1,111 @@ +use crate::{utils::parse_pubkey, DoubleZeroClient}; +use doublezero_serviceability::state::{ + accountdata::AccountData, accounttype::AccountType, feed::Feed, +}; +use solana_sdk::pubkey::Pubkey; + +#[derive(Debug, PartialEq, Clone)] +pub struct GetFeedCommand { + pub pubkey_or_code: String, +} + +impl GetFeedCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result<(Pubkey, Feed)> { + match parse_pubkey(&self.pubkey_or_code) { + Some(pk) => match client.get(pk)? { + AccountData::Feed(feed) => Ok((pk, feed)), + _ => Err(eyre::eyre!("Invalid Account Type")), + }, + None => client + .gets(AccountType::Feed)? + .into_iter() + .find(|(_, v)| match v { + AccountData::Feed(feed) => feed.code.eq_ignore_ascii_case(&self.pubkey_or_code), + _ => false, + }) + .map(|(pk, v)| match v { + AccountData::Feed(feed) => Ok((pk, feed)), + _ => Err(eyre::eyre!("Invalid Account Type")), + }) + .unwrap_or_else(|| { + Err(eyre::eyre!( + "Feed with code {} not found", + self.pubkey_or_code + )) + }), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{commands::feed::get::GetFeedCommand, tests::utils::create_test_client}; + use doublezero_serviceability::state::{ + accountdata::AccountData, accounttype::AccountType, feed::Feed, + }; + use mockall::predicate; + use solana_sdk::pubkey::Pubkey; + use std::collections::HashMap; + + #[test] + fn test_commands_feed_get_command() { + let mut client = create_test_client(); + + let feed_pubkey = Pubkey::new_unique(); + let feed = Feed { + account_type: AccountType::Feed, + owner: Pubkey::new_unique(), + bump_seed: 0, + code: "feed_code".to_string(), + name: "feed_name".to_string(), + reference_count: 0, + metros: vec![], + }; + + let feed2 = feed.clone(); + client + .expect_get() + .with(predicate::eq(feed_pubkey)) + .returning(move |_| Ok(AccountData::Feed(feed2.clone()))); + + let feed2 = feed.clone(); + client + .expect_gets() + .with(predicate::eq(AccountType::Feed)) + .returning(move |_| { + let mut feeds = HashMap::new(); + feeds.insert(feed_pubkey, AccountData::Feed(feed2.clone())); + Ok(feeds) + }); + + // Search by pubkey + let res = GetFeedCommand { + pubkey_or_code: feed_pubkey.to_string(), + } + .execute(&client); + assert!(res.is_ok()); + assert_eq!(res.unwrap().1.code, "feed_code".to_string()); + + // Search by code + let res = GetFeedCommand { + pubkey_or_code: "feed_code".to_string(), + } + .execute(&client); + assert!(res.is_ok()); + assert_eq!(res.unwrap().1.code, "feed_code".to_string()); + + // Search by code UPPERCASE + let res = GetFeedCommand { + pubkey_or_code: "FEED_CODE".to_string(), + } + .execute(&client); + assert!(res.is_ok()); + + // Invalid search + let res = GetFeedCommand { + pubkey_or_code: "ssssssssssss".to_string(), + } + .execute(&client); + assert!(res.is_err()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/feed/list.rs b/smartcontract/sdk/rs/src/commands/feed/list.rs new file mode 100644 index 000000000..7201fc9a8 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/feed/list.rs @@ -0,0 +1,82 @@ +use std::collections::HashMap; + +use crate::DoubleZeroClient; +use doublezero_serviceability::{ + error::DoubleZeroError, + state::{accountdata::AccountData, accounttype::AccountType, feed::Feed}, +}; +use solana_sdk::pubkey::Pubkey; + +#[derive(Debug, PartialEq, Clone)] +pub struct ListFeedCommand; + +impl ListFeedCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result> { + client + .gets(AccountType::Feed)? + .into_iter() + .map(|(k, v)| { + if let AccountData::Feed(feed) = v { + Ok((k, feed)) + } else { + Err(DoubleZeroError::InvalidAccountType.into()) + } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::{commands::feed::list::ListFeedCommand, tests::utils::create_test_client}; + use doublezero_serviceability::state::{ + accountdata::AccountData, accounttype::AccountType, feed::Feed, + }; + use mockall::predicate; + use solana_sdk::pubkey::Pubkey; + + #[test] + fn test_commands_feed_list_command() { + let mut client = create_test_client(); + + let feed1_pubkey = Pubkey::new_unique(); + let feed1 = Feed { + account_type: AccountType::Feed, + owner: Pubkey::new_unique(), + bump_seed: 0, + code: "feed1_code".to_string(), + name: "feed1_name".to_string(), + reference_count: 0, + metros: vec![], + }; + + let feed2_pubkey = Pubkey::new_unique(); + let feed2 = Feed { + account_type: AccountType::Feed, + owner: Pubkey::new_unique(), + bump_seed: 0, + code: "feed2_code".to_string(), + name: "feed2_name".to_string(), + reference_count: 0, + metros: vec![], + }; + + client + .expect_gets() + .with(predicate::eq(AccountType::Feed)) + .returning(move |_| { + let mut feeds = HashMap::new(); + feeds.insert(feed1_pubkey, AccountData::Feed(feed1.clone())); + feeds.insert(feed2_pubkey, AccountData::Feed(feed2.clone())); + Ok(feeds) + }); + + let res = ListFeedCommand.execute(&client); + assert!(res.is_ok()); + let list = res.unwrap(); + assert!(list.len() == 2); + assert!(list.contains_key(&feed1_pubkey)); + } +} diff --git a/smartcontract/sdk/rs/src/commands/feed/mod.rs b/smartcontract/sdk/rs/src/commands/feed/mod.rs new file mode 100644 index 000000000..91259509f --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/feed/mod.rs @@ -0,0 +1,5 @@ +pub mod create; +pub mod delete; +pub mod get; +pub mod list; +pub mod update; diff --git a/smartcontract/sdk/rs/src/commands/feed/update.rs b/smartcontract/sdk/rs/src/commands/feed/update.rs new file mode 100644 index 000000000..0775906d3 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/feed/update.rs @@ -0,0 +1,78 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, processors::feed::update::FeedUpdateArgs, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct UpdateFeedCommand { + pub pubkey: Pubkey, + pub name: Option, + /// `exchange_pk → group_pks`. `None` leaves the metro map unchanged. + pub metros: Option)>>, +} + +impl UpdateFeedCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + // Accounts: [feed, globalstate, (payer, system appended by client)]. + client.execute_transaction( + DoubleZeroInstruction::UpdateFeed(FeedUpdateArgs { + name: self.name.clone(), + metros: self.metros.clone(), + }), + vec![ + AccountMeta::new(self.pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::feed::update::UpdateFeedCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_feed_pda, get_globalstate_pda}, + processors::feed::update::FeedUpdateArgs, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, signature::Signature}; + + #[test] + fn test_commands_feed_update_command() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _globalstate) = get_globalstate_pda(&client.get_program_id()); + let (pda_pubkey, _) = get_feed_pda(&client.get_program_id(), "test_feed"); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::UpdateFeed(FeedUpdateArgs { + name: Some("Test Feed".to_string()), + metros: None, + })), + predicate::eq(vec![ + AccountMeta::new(pda_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = UpdateFeedCommand { + pubkey: pda_pubkey, + name: Some("Test Feed".to_string()), + metros: None, + } + .execute(&client); + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/mod.rs b/smartcontract/sdk/rs/src/commands/mod.rs index 77bc50a37..0f338a714 100644 --- a/smartcontract/sdk/rs/src/commands/mod.rs +++ b/smartcontract/sdk/rs/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod allowlist; pub mod contributor; pub mod device; pub mod exchange; +pub mod feed; pub mod globalconfig; pub mod globalstate; pub mod index; diff --git a/smartcontract/sdk/rs/src/lib.rs b/smartcontract/sdk/rs/src/lib.rs index e4bd9d0a7..6e68bb3da 100644 --- a/smartcontract/sdk/rs/src/lib.rs +++ b/smartcontract/sdk/rs/src/lib.rs @@ -6,9 +6,9 @@ pub use crate::config::{ pub use doublezero_serviceability::{ addresses::*, pda::{ - get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_link_pda, - get_location_pda, get_multicastgroup_pda, get_permission_pda, get_resource_extension_pda, - get_tenant_pda, get_topology_pda, get_user_old_pda, + get_contributor_pda, get_device_pda, get_exchange_pda, get_feed_pda, get_globalconfig_pda, + get_link_pda, get_location_pda, get_multicastgroup_pda, get_permission_pda, + get_resource_extension_pda, get_tenant_pda, get_topology_pda, get_user_old_pda, }, programversion::ProgramVersion, resource::{IdOrIp, ResourceType}, @@ -18,6 +18,7 @@ pub use doublezero_serviceability::{ contributor::{Contributor, ContributorStatus}, device::{Device, DeviceStatus, DeviceType}, exchange::{Exchange, ExchangeStatus, BGP_COMMUNITY_MAX, BGP_COMMUNITY_MIN}, + feed::Feed, globalconfig::GlobalConfig, globalstate::GlobalState, interface::{Interface, InterfaceDeprecated, InterfaceStatus, InterfaceType, LoopbackType},