diff --git a/Cargo.lock b/Cargo.lock index 169b21a87a1..93eef674bcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7188,6 +7188,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "spl-token-group-interface" +version = "0.1.0" +dependencies = [ + "bytemuck", + "solana-program", + "spl-discriminator 0.1.0", + "spl-pod 0.1.0", + "spl-program-error 0.3.0", + "spl-type-length-value 0.3.0", +] + [[package]] name = "spl-token-lending" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index b7e44e1768a..4825c596d30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ members = [ "stake-pool/cli", "stake-pool/program", "stateless-asks/program", + "token-group/interface", "token-lending/cli", "token-lending/program", "token-metadata/example", diff --git a/token-group/interface/Cargo.toml b/token-group/interface/Cargo.toml new file mode 100644 index 00000000000..b748beab31b --- /dev/null +++ b/token-group/interface/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "spl-token-group-interface" +version = "0.1.0" +description = "Solana Program Library Token Group Interface" +authors = ["Solana Labs Maintainers "] +repository = "https://github.com/solana-labs/solana-program-library" +license = "Apache-2.0" +edition = "2021" + +[dependencies] +bytemuck = "1.14.0" +solana-program = "1.16.16" +spl-discriminator = { version = "0.1.0" , path = "../../libraries/discriminator" } +spl-pod = { version = "0.1.0" , path = "../../libraries/pod", features = ["borsh"] } +spl-program-error = { version = "0.3.0" , path = "../../libraries/program-error" } + +[dev-dependencies] +spl-type-length-value = { version = "0.3.0", path = "../../libraries/type-length-value", features = ["derive"] } + +[lib] +crate-type = ["cdylib", "lib"] + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/token-group/interface/src/error.rs b/token-group/interface/src/error.rs new file mode 100644 index 00000000000..f2eff4d60f2 --- /dev/null +++ b/token-group/interface/src/error.rs @@ -0,0 +1,14 @@ +//! Interface error types + +use spl_program_error::*; + +/// Errors that may be returned by the interface. +#[spl_program_error] +pub enum TokenGroupError { + /// Size is greater than proposed max size + #[error("Size is greater than proposed max size")] + SizeExceedsNewMaxSize, + /// Size is greater than max size + #[error("Size is greater than max size")] + SizeExceedsMaxSize, +} diff --git a/token-group/interface/src/instruction.rs b/token-group/interface/src/instruction.rs new file mode 100644 index 00000000000..67536d896bd --- /dev/null +++ b/token-group/interface/src/instruction.rs @@ -0,0 +1,308 @@ +//! Instruction types + +use { + bytemuck::{Pod, Zeroable}, + solana_program::{ + instruction::{AccountMeta, Instruction}, + program_error::ProgramError, + pubkey::Pubkey, + }, + spl_discriminator::{ArrayDiscriminator, SplDiscriminate}, + spl_pod::{ + bytemuck::{pod_bytes_of, pod_from_bytes}, + optional_keys::OptionalNonZeroPubkey, + primitives::PodU32, + }, +}; + +/// Instruction data for initializing a new `Group` +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable, SplDiscriminate)] +#[discriminator_hash_input("spl_token_group_interface:initialize_token_group")] +pub struct InitializeGroup { + /// Update authority for the group + pub update_authority: OptionalNonZeroPubkey, + /// The maximum number of group members + pub max_size: PodU32, +} + +/// Instruction data for updating the max size of a `Group` +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable, SplDiscriminate)] +#[discriminator_hash_input("spl_token_group_interface:update_group_max_size")] +pub struct UpdateGroupMaxSize { + /// New max size for the group + pub max_size: PodU32, +} + +/// Instruction data for updating the authority of a `Group` +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable, SplDiscriminate)] +#[discriminator_hash_input("spl_token_group_interface:update_authority")] +pub struct UpdateGroupAuthority { + /// New authority for the group, or unset if `None` + pub new_authority: OptionalNonZeroPubkey, +} + +/// Instruction data for initializing a new `Member` of a `Group` +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable, SplDiscriminate)] +#[discriminator_hash_input("spl_token_group_interface:initialize_member")] +pub struct InitializeMember; + +/// All instructions that must be implemented in the SPL Token Group Interface +#[derive(Clone, Debug, PartialEq)] +pub enum TokenGroupInstruction { + /// Initialize a new `Group` + /// + /// Assumes one has already initialized a mint for the + /// group. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[w]` Group + /// 1. `[]` Mint + /// 2. `[s]` Mint authority + InitializeGroup(InitializeGroup), + + /// Update the max size of a `Group` + /// + /// Accounts expected by this instruction: + /// + /// 0. `[w]` Group + /// 1. `[s]` Update authority + UpdateGroupMaxSize(UpdateGroupMaxSize), + + /// Update the authority of a `Group` + /// + /// Accounts expected by this instruction: + /// + /// 0. `[w]` Group + /// 1. `[s]` Current update authority + UpdateGroupAuthority(UpdateGroupAuthority), + + /// Initialize a new `Member` of a `Group` + /// + /// Assumes the `Group` has already been initialized, + /// as well as the mint for the member. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[w]` Member + /// 1. `[]` Member mint + /// 1. `[s]` Member mint authority + /// 2. `[w]` Group + /// 3. `[s]` Group update authority + InitializeMember(InitializeMember), +} +impl TokenGroupInstruction { + /// Unpacks a byte buffer into a `TokenGroupInstruction` + pub fn unpack(input: &[u8]) -> Result { + if input.len() < ArrayDiscriminator::LENGTH { + return Err(ProgramError::InvalidInstructionData); + } + let (discriminator, rest) = input.split_at(ArrayDiscriminator::LENGTH); + Ok(match discriminator { + InitializeGroup::SPL_DISCRIMINATOR_SLICE => { + let data = pod_from_bytes::(rest)?; + Self::InitializeGroup(*data) + } + UpdateGroupMaxSize::SPL_DISCRIMINATOR_SLICE => { + let data = pod_from_bytes::(rest)?; + Self::UpdateGroupMaxSize(*data) + } + UpdateGroupAuthority::SPL_DISCRIMINATOR_SLICE => { + let data = pod_from_bytes::(rest)?; + Self::UpdateGroupAuthority(*data) + } + InitializeMember::SPL_DISCRIMINATOR_SLICE => { + let data = pod_from_bytes::(rest)?; + Self::InitializeMember(*data) + } + _ => return Err(ProgramError::InvalidInstructionData), + }) + } + + /// Packs a `TokenGroupInstruction` into a byte buffer. + pub fn pack(&self) -> Vec { + let mut buf = vec![]; + match self { + Self::InitializeGroup(data) => { + buf.extend_from_slice(InitializeGroup::SPL_DISCRIMINATOR_SLICE); + buf.extend_from_slice(pod_bytes_of(data)); + } + Self::UpdateGroupMaxSize(data) => { + buf.extend_from_slice(UpdateGroupMaxSize::SPL_DISCRIMINATOR_SLICE); + buf.extend_from_slice(pod_bytes_of(data)); + } + Self::UpdateGroupAuthority(data) => { + buf.extend_from_slice(UpdateGroupAuthority::SPL_DISCRIMINATOR_SLICE); + buf.extend_from_slice(pod_bytes_of(data)); + } + Self::InitializeMember(data) => { + buf.extend_from_slice(InitializeMember::SPL_DISCRIMINATOR_SLICE); + buf.extend_from_slice(pod_bytes_of(data)); + } + }; + buf + } +} + +/// Creates a `InitializeGroup` instruction +pub fn initialize_group( + program_id: &Pubkey, + group: &Pubkey, + mint: &Pubkey, + mint_authority: &Pubkey, + update_authority: Option, + max_size: u32, +) -> Instruction { + let update_authority = OptionalNonZeroPubkey::try_from(update_authority) + .expect("Failed to deserialize `Option`"); + let data = TokenGroupInstruction::InitializeGroup(InitializeGroup { + update_authority, + max_size: max_size.into(), + }) + .pack(); + Instruction { + program_id: *program_id, + accounts: vec![ + AccountMeta::new(*group, false), + AccountMeta::new_readonly(*mint, false), + AccountMeta::new_readonly(*mint_authority, true), + ], + data, + } +} + +/// Creates a `UpdateGroupMaxSize` instruction +pub fn update_group_max_size( + program_id: &Pubkey, + group: &Pubkey, + update_authority: &Pubkey, + max_size: u32, +) -> Instruction { + let data = TokenGroupInstruction::UpdateGroupMaxSize(UpdateGroupMaxSize { + max_size: max_size.into(), + }) + .pack(); + Instruction { + program_id: *program_id, + accounts: vec![ + AccountMeta::new(*group, false), + AccountMeta::new_readonly(*update_authority, true), + ], + data, + } +} + +/// Creates a `UpdateGroupAuthority` instruction +pub fn update_group_authority( + program_id: &Pubkey, + group: &Pubkey, + current_authority: &Pubkey, + new_authority: Option, +) -> Instruction { + let new_authority = OptionalNonZeroPubkey::try_from(new_authority) + .expect("Failed to deserialize `Option`"); + let data = + TokenGroupInstruction::UpdateGroupAuthority(UpdateGroupAuthority { new_authority }).pack(); + Instruction { + program_id: *program_id, + accounts: vec![ + AccountMeta::new(*group, false), + AccountMeta::new_readonly(*current_authority, true), + ], + data, + } +} + +/// Creates a `InitializeMember` instruction +#[allow(clippy::too_many_arguments)] +pub fn initialize_member( + program_id: &Pubkey, + member: &Pubkey, + member_mint: &Pubkey, + member_mint_authority: &Pubkey, + group: &Pubkey, + group_update_authority: &Pubkey, +) -> Instruction { + let data = TokenGroupInstruction::InitializeMember(InitializeMember {}).pack(); + Instruction { + program_id: *program_id, + accounts: vec![ + AccountMeta::new(*member, false), + AccountMeta::new_readonly(*member_mint, false), + AccountMeta::new_readonly(*member_mint_authority, true), + AccountMeta::new(*group, false), + AccountMeta::new_readonly(*group_update_authority, true), + ], + data, + } +} + +#[cfg(test)] +mod test { + use {super::*, crate::NAMESPACE, solana_program::hash}; + + #[repr(C)] + #[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable, SplDiscriminate)] + #[discriminator_hash_input("mock_group")] + struct MockGroup; + + fn instruction_pack_unpack(instruction: TokenGroupInstruction, discriminator: &[u8], data: I) + where + I: core::fmt::Debug + PartialEq + Pod + Zeroable + SplDiscriminate, + { + let mut expect = vec![]; + expect.extend_from_slice(discriminator.as_ref()); + expect.extend_from_slice(pod_bytes_of(&data)); + let packed = instruction.pack(); + assert_eq!(packed, expect); + let unpacked = TokenGroupInstruction::unpack(&expect).unwrap(); + assert_eq!(unpacked, instruction); + } + + #[test] + fn initialize_group_pack() { + let data = InitializeGroup { + update_authority: OptionalNonZeroPubkey::default(), + max_size: 100.into(), + }; + let instruction = TokenGroupInstruction::InitializeGroup(data); + let preimage = hash::hashv(&[format!("{NAMESPACE}:initialize_token_group").as_bytes()]); + let discriminator = &preimage.as_ref()[..ArrayDiscriminator::LENGTH]; + instruction_pack_unpack::(instruction, discriminator, data); + } + + #[test] + fn update_group_max_size_pack() { + let data = UpdateGroupMaxSize { + max_size: 200.into(), + }; + let instruction = TokenGroupInstruction::UpdateGroupMaxSize(data); + let preimage = hash::hashv(&[format!("{NAMESPACE}:update_group_max_size").as_bytes()]); + let discriminator = &preimage.as_ref()[..ArrayDiscriminator::LENGTH]; + instruction_pack_unpack::(instruction, discriminator, data); + } + + #[test] + fn update_authority_pack() { + let data = UpdateGroupAuthority { + new_authority: OptionalNonZeroPubkey::default(), + }; + let instruction = TokenGroupInstruction::UpdateGroupAuthority(data); + let preimage = hash::hashv(&[format!("{NAMESPACE}:update_authority").as_bytes()]); + let discriminator = &preimage.as_ref()[..ArrayDiscriminator::LENGTH]; + instruction_pack_unpack::(instruction, discriminator, data); + } + + #[test] + fn initialize_member_pack() { + let data = InitializeMember {}; + let instruction = TokenGroupInstruction::InitializeMember(data); + let preimage = hash::hashv(&[format!("{NAMESPACE}:initialize_member").as_bytes()]); + let discriminator = &preimage.as_ref()[..ArrayDiscriminator::LENGTH]; + instruction_pack_unpack::(instruction, discriminator, data); + } +} diff --git a/token-group/interface/src/lib.rs b/token-group/interface/src/lib.rs new file mode 100644 index 00000000000..6867b86acf8 --- /dev/null +++ b/token-group/interface/src/lib.rs @@ -0,0 +1,11 @@ +//! Crate defining the SPL Token Group Interface + +#![deny(missing_docs)] +#![cfg_attr(not(test), forbid(unsafe_code))] + +pub mod error; +pub mod instruction; +pub mod state; + +/// Namespace for all programs implementing spl-token-group +pub const NAMESPACE: &str = "spl_token_group_interface"; diff --git a/token-group/interface/src/state.rs b/token-group/interface/src/state.rs new file mode 100644 index 00000000000..592fc0bb71e --- /dev/null +++ b/token-group/interface/src/state.rs @@ -0,0 +1,182 @@ +//! Interface state types + +use { + crate::error::TokenGroupError, + bytemuck::{Pod, Zeroable}, + solana_program::{program_error::ProgramError, pubkey::Pubkey}, + spl_discriminator::SplDiscriminate, + spl_pod::{error::PodSliceError, optional_keys::OptionalNonZeroPubkey, primitives::PodU32}, +}; + +/// Data struct for a `TokenGroup` +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable, SplDiscriminate)] +#[discriminator_hash_input("spl_token_group_interface:group")] +pub struct TokenGroup { + /// The authority that can sign to update the group + pub update_authority: OptionalNonZeroPubkey, + /// The current number of group members + pub size: PodU32, + /// The maximum number of group members + pub max_size: PodU32, +} + +impl TokenGroup { + /// Creates a new `TokenGroup` state + pub fn new(update_authority: OptionalNonZeroPubkey, max_size: u32) -> Self { + Self { + update_authority, + size: PodU32::default(), // [0, 0, 0, 0] + max_size: max_size.into(), + } + } + + /// Updates the max size for a group + pub fn update_max_size(&mut self, new_max_size: u32) -> Result<(), ProgramError> { + // The new max size cannot be less than the current size + if new_max_size < u32::from(self.size) { + return Err(TokenGroupError::SizeExceedsNewMaxSize.into()); + } + self.max_size = new_max_size.into(); + Ok(()) + } + + /// Increment the size for a group, returning the new size + pub fn increment_size(&mut self) -> Result { + // The new size cannot be greater than the max size + let new_size = u32::from(self.size) + .checked_add(1) + .ok_or::(PodSliceError::CalculationFailure.into())?; + if new_size > u32::from(self.max_size) { + return Err(TokenGroupError::SizeExceedsMaxSize.into()); + } + self.size = new_size.into(); + Ok(new_size) + } +} + +/// Data struct for a `TokenGroupMember` +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable, SplDiscriminate)] +#[discriminator_hash_input("spl_token_group_interface:member")] +pub struct TokenGroupMember { + /// The pubkey of the `TokenGroup` + pub group: Pubkey, + /// The member number + pub member_number: PodU32, +} +impl TokenGroupMember { + /// Creates a new `TokenGroupMember` state + pub fn new(group: Pubkey, member_number: u32) -> Self { + Self { + group, + member_number: member_number.into(), + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::NAMESPACE, + solana_program::hash, + spl_discriminator::ArrayDiscriminator, + spl_type_length_value::state::{TlvState, TlvStateBorrowed, TlvStateMut}, + std::mem::size_of, + }; + + #[test] + fn discriminators() { + let preimage = hash::hashv(&[format!("{NAMESPACE}:group").as_bytes()]); + let discriminator = + ArrayDiscriminator::try_from(&preimage.as_ref()[..ArrayDiscriminator::LENGTH]).unwrap(); + assert_eq!(TokenGroup::SPL_DISCRIMINATOR, discriminator); + + let preimage = hash::hashv(&[format!("{NAMESPACE}:member").as_bytes()]); + let discriminator = + ArrayDiscriminator::try_from(&preimage.as_ref()[..ArrayDiscriminator::LENGTH]).unwrap(); + assert_eq!(TokenGroupMember::SPL_DISCRIMINATOR, discriminator); + } + + #[test] + fn tlv_state_pack() { + // Make sure we can pack more than one instance of each type + let group = TokenGroup { + update_authority: OptionalNonZeroPubkey::try_from(Some(Pubkey::new_unique())).unwrap(), + size: 10.into(), + max_size: 20.into(), + }; + + let member = TokenGroupMember { + group: Pubkey::new_unique(), + member_number: 0.into(), + }; + + let account_size = TlvStateBorrowed::get_base_len() + + size_of::() + + TlvStateBorrowed::get_base_len() + + size_of::(); + let mut buffer = vec![0; account_size]; + let mut state = TlvStateMut::unpack(&mut buffer).unwrap(); + + let group_data = state.init_value::(false).unwrap().0; + *group_data = group; + + let member_data = state.init_value::(false).unwrap().0; + *member_data = member; + + assert_eq!(state.get_first_value::().unwrap(), &group); + assert_eq!( + state.get_first_value::().unwrap(), + &member + ); + } + + #[test] + fn update_max_size() { + // Test with a `Some` max size + let max_size = 10; + let mut group = TokenGroup { + update_authority: OptionalNonZeroPubkey::try_from(Some(Pubkey::new_unique())).unwrap(), + size: 0.into(), + max_size: max_size.into(), + }; + + let new_max_size = 30; + group.update_max_size(new_max_size).unwrap(); + assert_eq!(u32::from(group.max_size), new_max_size); + + // Change the current size to 30 + group.size = 30.into(); + + // Try to set the max size to 20, which is less than the current size + let new_max_size = 20; + assert_eq!( + group.update_max_size(new_max_size), + Err(ProgramError::from(TokenGroupError::SizeExceedsNewMaxSize)) + ); + + let new_max_size = 30; + group.update_max_size(new_max_size).unwrap(); + assert_eq!(u32::from(group.max_size), new_max_size); + } + + #[test] + fn increment_current_size() { + let mut group = TokenGroup { + update_authority: OptionalNonZeroPubkey::try_from(Some(Pubkey::new_unique())).unwrap(), + size: 0.into(), + max_size: 1.into(), + }; + + group.increment_size().unwrap(); + assert_eq!(u32::from(group.size), 1); + + // Try to increase the current size to 2, which is greater than the max size + assert_eq!( + group.increment_size(), + Err(ProgramError::from(TokenGroupError::SizeExceedsMaxSize)) + ); + } +}