From 989c80777cd552ba0fec5cd78df1f6508676704e Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 19 Oct 2023 14:00:11 +0200 Subject: [PATCH] token collections: create spec program --- .../pull-request-token-collections.yml | 66 +++ Cargo.lock | 17 + Cargo.toml | 1 + token-collection/README.md | 24 ++ token-collection/program/Cargo.toml | 33 ++ token-collection/program/src/entrypoint.rs | 23 ++ token-collection/program/src/lib.rs | 10 + token-collection/program/src/processor.rs | 206 ++++++++++ token-collection/program/tests/setup.rs | 123 ++++++ .../program/tests/token_collection.rs | 389 ++++++++++++++++++ 10 files changed, 892 insertions(+) create mode 100644 .github/workflows/pull-request-token-collections.yml create mode 100644 token-collection/README.md create mode 100644 token-collection/program/Cargo.toml create mode 100644 token-collection/program/src/entrypoint.rs create mode 100644 token-collection/program/src/lib.rs create mode 100644 token-collection/program/src/processor.rs create mode 100644 token-collection/program/tests/setup.rs create mode 100644 token-collection/program/tests/token_collection.rs diff --git a/.github/workflows/pull-request-token-collections.yml b/.github/workflows/pull-request-token-collections.yml new file mode 100644 index 00000000000..1a21b772dd5 --- /dev/null +++ b/.github/workflows/pull-request-token-collections.yml @@ -0,0 +1,66 @@ +name: Token Collections Pull Request + +on: + pull_request: + paths: + - 'token-collections/**' + - 'token/**' + - 'token-group/**' + - 'token-metadata/**' + - 'ci/*-version.sh' + - '.github/workflows/pull-request-token-collections.yml' + push: + branches: [master] + paths: + - 'token-collections/**' + - 'token/**' + - 'token-group/**' + - 'token-metadata/**' + - 'ci/*-version.sh' + - '.github/workflows/pull-request-token-collections.yml' + +jobs: + cargo-test-sbf: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set env vars + run: | + source ci/rust-version.sh + echo "RUST_STABLE=$rust_stable" >> $GITHUB_ENV + source ci/solana-version.sh + echo "SOLANA_VERSION=$solana_version" >> $GITHUB_ENV + + - uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_STABLE }} + override: true + profile: minimal + + - uses: actions/cache@v2 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: cargo-build-${{ hashFiles('**/Cargo.lock') }}-${{ env.RUST_STABLE}} + + - uses: actions/cache@v2 + with: + path: | + ~/.cargo/bin/rustfilt + key: cargo-sbf-bins-${{ runner.os }} + + - uses: actions/cache@v2 + with: + path: ~/.cache/solana + key: solana-${{ env.SOLANA_VERSION }} + + - name: Install dependencies + run: | + ./ci/install-build-deps.sh + ./ci/install-program-deps.sh + echo "$HOME/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH + + - name: Build and test + run: ./ci/cargo-test-sbf.sh token-collections diff --git a/Cargo.lock b/Cargo.lock index 8d61f98e418..70797190722 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7341,6 +7341,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "spl-token-collection" +version = "0.1.0" +dependencies = [ + "solana-program", + "solana-program-test", + "solana-sdk", + "spl-discriminator 0.1.0", + "spl-pod 0.1.0", + "spl-program-error 0.3.0", + "spl-token-2022 0.9.0", + "spl-token-client", + "spl-token-group-interface", + "spl-token-metadata-interface 0.2.0", + "spl-type-length-value 0.3.0", +] + [[package]] name = "spl-token-group-example" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f6f1dd68302..7f82f492a39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ members = [ "stake-pool/cli", "stake-pool/program", "stateless-asks/program", + "token-collection/program", "token-group/example", "token-group/interface", "token-lending/cli", diff --git a/token-collection/README.md b/token-collection/README.md new file mode 100644 index 00000000000..0b311156f35 --- /dev/null +++ b/token-collection/README.md @@ -0,0 +1,24 @@ +# SPL Token Collection + +This program serves as a reference implementation for using the SPL Token Group +interface to create an on-chain program for managing token collections - such +as NFT Collections. + +This program bears a lot of similarity to the example program found at +`token-group/example`, but with some additional implementations centered around +specifically token collections. + +## How Collections Work in this Program + +Strictly for demonstration purposes, this program is going to require the +following: + +- Group tokens must be NFTs (0 decimals, 1 supply) +- Group tokens must have metadata +- Member tokens can be any SPL token, but must have metadata +- Member tokens can be part of multiple collections + +## Demonstration + +For a particularly fleshed-out example of this program in action, check out the +`token-collections.rs` test under `tests`! \ No newline at end of file diff --git a/token-collection/program/Cargo.toml b/token-collection/program/Cargo.toml new file mode 100644 index 00000000000..713e39558ad --- /dev/null +++ b/token-collection/program/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "spl-token-collection" +version = "0.1.0" +description = "Solana Program Library Token Collection" +authors = ["Solana Labs Maintainers "] +repository = "https://github.com/solana-labs/solana-program-library" +license = "Apache-2.0" +edition = "2021" + +[features] +no-entrypoint = [] +test-sbf = [] + +[dependencies] +solana-program = "1.17.2" +spl-pod = { version = "0.1.0", path = "../../libraries/pod" } +spl-program-error = { version = "0.3.0" , path = "../../libraries/program-error" } +spl-token-2022 = { version = "0.9.0", path = "../../token/program-2022", features = ["no-entrypoint"] } +spl-token-group-interface = { version = "0.1.0", path = "../../token-group/interface" } +spl-token-metadata-interface = { version = "0.2", path = "../../token-metadata/interface" } +spl-type-length-value = { version = "0.3.0", path = "../../libraries/type-length-value" } + +[dev-dependencies] +solana-program-test = "1.17.2" +solana-sdk = "1.17.2" +spl-discriminator = { version = "0.1.0", path = "../../libraries/discriminator" } +spl-token-client = { version = "0.7", path = "../../token/client" } + +[lib] +crate-type = ["cdylib", "lib"] + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] \ No newline at end of file diff --git a/token-collection/program/src/entrypoint.rs b/token-collection/program/src/entrypoint.rs new file mode 100644 index 00000000000..762ec75255a --- /dev/null +++ b/token-collection/program/src/entrypoint.rs @@ -0,0 +1,23 @@ +//! Program entrypoint + +use { + crate::processor, + solana_program::{ + account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, + program_error::PrintProgramError, pubkey::Pubkey, + }, + spl_token_group_interface::error::TokenGroupError, +}; + +entrypoint!(process_instruction); +fn process_instruction( + program_id: &Pubkey, + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> ProgramResult { + if let Err(error) = processor::process(program_id, accounts, instruction_data) { + error.print::(); + return Err(error); + } + Ok(()) +} diff --git a/token-collection/program/src/lib.rs b/token-collection/program/src/lib.rs new file mode 100644 index 00000000000..6e539b2fd80 --- /dev/null +++ b/token-collection/program/src/lib.rs @@ -0,0 +1,10 @@ +//! Crate defining the Token Collection program implementing the +//! SPL Token Group interface. + +#![deny(missing_docs)] +#![forbid(unsafe_code)] + +pub mod processor; + +#[cfg(not(feature = "no-entrypoint"))] +mod entrypoint; diff --git a/token-collection/program/src/processor.rs b/token-collection/program/src/processor.rs new file mode 100644 index 00000000000..edfcf9103d4 --- /dev/null +++ b/token-collection/program/src/processor.rs @@ -0,0 +1,206 @@ +//! Program state processor + +use { + solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + program_option::COption, + pubkey::Pubkey, + }, + spl_pod::optional_keys::OptionalNonZeroPubkey, + spl_token_2022::{ + extension::{ + metadata_pointer::MetadataPointer, BaseStateWithExtensions, StateWithExtensions, + }, + state::Mint, + }, + spl_token_group_interface::{ + error::TokenGroupError, + instruction::{ + InitializeGroup, TokenGroupInstruction, UpdateGroupAuthority, UpdateGroupMaxSize, + }, + state::{TokenGroup, TokenGroupMember}, + }, + spl_token_metadata_interface::state::TokenMetadata, + spl_type_length_value::state::TlvStateMut, +}; + +fn check_update_authority( + update_authority_info: &AccountInfo, + expected_update_authority: &OptionalNonZeroPubkey, +) -> ProgramResult { + if !update_authority_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + let update_authority = Option::::from(*expected_update_authority) + .ok_or(TokenGroupError::ImmutableGroup)?; + if update_authority != *update_authority_info.key { + return Err(TokenGroupError::IncorrectUpdateAuthority.into()); + } + Ok(()) +} + +/// Checks that a mint is valid and contains metadata. +fn check_mint_and_metadata( + mint_info: &AccountInfo, + mint_authority_info: &AccountInfo, +) -> ProgramResult { + let mint_data = mint_info.try_borrow_data()?; + let mint = StateWithExtensions::::unpack(&mint_data)?; + + if !mint_authority_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + if mint.base.mint_authority.as_ref() != COption::Some(mint_authority_info.key) { + return Err(TokenGroupError::IncorrectMintAuthority.into()); + } + + let metadata_pointer = mint.get_extension::()?; + let metadata_pointer_address = Option::::from(metadata_pointer.metadata_address); + + // If the metadata is inside the mint (Token2022), make sure it contains + // valid TokenMetadata + if metadata_pointer_address == Some(*mint_info.key) { + mint.get_variable_len_extension::()?; + } + + Ok(()) +} + +/// Processes an [InitializeGroup](enum.GroupInterfaceInstruction.html) +/// instruction to initialize a collection. +pub fn process_initialize_collection( + _program_id: &Pubkey, + accounts: &[AccountInfo], + data: InitializeGroup, +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + + let collection_info = next_account_info(account_info_iter)?; + let mint_info = next_account_info(account_info_iter)?; + let mint_authority_info = next_account_info(account_info_iter)?; + + check_mint_and_metadata(mint_info, mint_authority_info)?; + + // Initialize the collection + let mut buffer = collection_info.try_borrow_mut_data()?; + let mut state = TlvStateMut::unpack(&mut buffer)?; + let (collection, _) = state.init_value::(false)?; + *collection = TokenGroup::new(mint_info.key, data.update_authority, data.max_size.into()); + + Ok(()) +} + +/// Processes an +/// [UpdateGroupMaxSize](enum.GroupInterfaceInstruction.html) +/// instruction to update the max size of a collection. +pub fn process_update_collection_max_size( + _program_id: &Pubkey, + accounts: &[AccountInfo], + data: UpdateGroupMaxSize, +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + + let collection_info = next_account_info(account_info_iter)?; + let update_authority_info = next_account_info(account_info_iter)?; + + let mut buffer = collection_info.try_borrow_mut_data()?; + let mut state = TlvStateMut::unpack(&mut buffer)?; + let collection = state.get_first_value_mut::()?; + + check_update_authority(update_authority_info, &collection.update_authority)?; + + collection.update_max_size(data.max_size.into())?; + + Ok(()) +} + +/// Processes an +/// [UpdateGroupAuthority](enum.GroupInterfaceInstruction.html) +/// instruction to update the authority of a collection. +pub fn process_update_collection_authority( + _program_id: &Pubkey, + accounts: &[AccountInfo], + data: UpdateGroupAuthority, +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + + let collection_info = next_account_info(account_info_iter)?; + let update_authority_info = next_account_info(account_info_iter)?; + + let mut buffer = collection_info.try_borrow_mut_data()?; + let mut state = TlvStateMut::unpack(&mut buffer)?; + let collection = state.get_first_value_mut::()?; + + check_update_authority(update_authority_info, &collection.update_authority)?; + + collection.update_authority = data.new_authority; + + Ok(()) +} + +/// Processes an [InitializeMember](enum.GroupInterfaceInstruction.html) +/// instruction +pub fn process_initialize_collection_member( + _program_id: &Pubkey, + accounts: &[AccountInfo], +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + + let member_info = next_account_info(account_info_iter)?; + let mint_info = next_account_info(account_info_iter)?; + let mint_authority_info = next_account_info(account_info_iter)?; + let collection_info = next_account_info(account_info_iter)?; + let collection_update_authority_info = next_account_info(account_info_iter)?; + + check_mint_and_metadata(mint_info, mint_authority_info)?; + + if member_info.key == collection_info.key { + return Err(TokenGroupError::MemberAccountIsGroupAccount.into()); + } + + let mut buffer = collection_info.try_borrow_mut_data()?; + let mut state = TlvStateMut::unpack(&mut buffer)?; + let collection = state.get_first_value_mut::()?; + + check_update_authority( + collection_update_authority_info, + &collection.update_authority, + )?; + let member_number = collection.increment_size()?; + + let mut buffer = member_info.try_borrow_mut_data()?; + let mut state = TlvStateMut::unpack(&mut buffer)?; + + // This program uses `allow_repetition: true` because the same mint can be + // a member of multiple collections. + let (member, _) = state.init_value::(/* allow_repetition */ true)?; + *member = TokenGroupMember::new(mint_info.key, collection_info.key, member_number); + + Ok(()) +} + +/// Processes an `SplTokenGroupInstruction` +pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult { + let instruction = TokenGroupInstruction::unpack(input)?; + match instruction { + TokenGroupInstruction::InitializeGroup(data) => { + msg!("Instruction: InitializeCollection"); + process_initialize_collection(program_id, accounts, data) + } + TokenGroupInstruction::UpdateGroupMaxSize(data) => { + msg!("Instruction: UpdateCollectionMaxSize"); + process_update_collection_max_size(program_id, accounts, data) + } + TokenGroupInstruction::UpdateGroupAuthority(data) => { + msg!("Instruction: UpdateCollectionAuthority"); + process_update_collection_authority(program_id, accounts, data) + } + TokenGroupInstruction::InitializeMember(_) => { + msg!("Instruction: InitializeCollectionMember"); + process_initialize_collection_member(program_id, accounts) + } + } +} diff --git a/token-collection/program/tests/setup.rs b/token-collection/program/tests/setup.rs new file mode 100644 index 00000000000..709f6f92943 --- /dev/null +++ b/token-collection/program/tests/setup.rs @@ -0,0 +1,123 @@ +#![cfg(feature = "test-sbf")] + +use { + solana_program::system_instruction, + solana_program_test::{processor, tokio::sync::Mutex, ProgramTest, ProgramTestContext}, + solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer, transaction::Transaction}, + spl_token_client::{ + client::{ + ProgramBanksClient, ProgramBanksClientProcessTransaction, ProgramClient, + SendTransaction, SimulateTransaction, + }, + token::{ExtensionInitializationParams, Token}, + }, + spl_token_group_interface::instruction::initialize_group, + spl_token_metadata_interface::state::TokenMetadata, + std::sync::Arc, +}; + +/// Set up a program test +pub async fn setup_program_test( + program_id: &Pubkey, +) -> ( + Arc>, + Arc>, + Arc, +) { + let mut program_test = ProgramTest::new( + "spl_token_collection", + *program_id, + processor!(spl_token_collection::processor::process), + ); + program_test.prefer_bpf(false); + program_test.add_program( + "spl_token_2022", + spl_token_2022::id(), + processor!(spl_token_2022::processor::Processor::process), + ); + let context = program_test.start_with_context().await; + let payer = Arc::new(context.payer.insecure_clone()); + let context = Arc::new(Mutex::new(context)); + let client: Arc> = + Arc::new(ProgramBanksClient::new_from_context( + Arc::clone(&context), + ProgramBanksClientProcessTransaction, + )); + (context, client, payer) +} + +/// Set up a Token-2022 mint and metadata +pub async fn setup_mint_and_metadata( + token_client: &Token, + mint_keypair: &Keypair, + mint_authority_keypair: &Keypair, + token_metadata: &TokenMetadata, + payer: Arc, +) { + token_client + .create_mint( + &mint_authority_keypair.pubkey(), + None, + vec![ExtensionInitializationParams::MetadataPointer { + authority: Some(mint_authority_keypair.pubkey()), + metadata_address: Some(mint_keypair.pubkey()), + }], + &[mint_keypair], + ) + .await + .unwrap(); + token_client + .token_metadata_initialize_with_rent_transfer( + &payer.pubkey(), + &mint_authority_keypair.pubkey(), + &mint_authority_keypair.pubkey(), + token_metadata.name.clone(), + token_metadata.symbol.clone(), + token_metadata.uri.clone(), + &[&payer, mint_authority_keypair], + ) + .await + .unwrap(); +} + +/// Initialize a token group +#[allow(clippy::too_many_arguments)] +pub async fn setup_group( + context: &mut ProgramTestContext, + program_id: &Pubkey, + group: &Keypair, + mint: &Keypair, + mint_authority: &Keypair, + update_authority: Option, + max_size: u32, + rent_lamports: u64, + space: usize, +) { + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::create_account( + &context.payer.pubkey(), + &group.pubkey(), + rent_lamports, + space.try_into().unwrap(), + program_id, + ), + initialize_group( + program_id, + &group.pubkey(), + &mint.pubkey(), + &mint_authority.pubkey(), + update_authority, + max_size, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, mint_authority, group], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); +} diff --git a/token-collection/program/tests/token_collection.rs b/token-collection/program/tests/token_collection.rs new file mode 100644 index 00000000000..aeff8994ef8 --- /dev/null +++ b/token-collection/program/tests/token_collection.rs @@ -0,0 +1,389 @@ +#![cfg(feature = "test-sbf")] + +mod setup; + +use { + setup::{setup_group, setup_mint_and_metadata, setup_program_test}, + solana_program::{pubkey::Pubkey, system_instruction}, + solana_program_test::tokio, + solana_sdk::{signature::Keypair, signer::Signer, transaction::Transaction}, + spl_token_client::token::Token, + spl_token_group_interface::{ + instruction::initialize_member, + state::{TokenGroup, TokenGroupMember}, + }, + spl_token_metadata_interface::state::TokenMetadata, + spl_type_length_value::state::{TlvState, TlvStateBorrowed}, +}; + +/// All snakes are reptiles, but not all reptiles are snakes. +#[tokio::test] +async fn test_token_collection() { + let program_id = Pubkey::new_unique(); + let (context, client, payer) = setup_program_test(&program_id).await; + + // Set up the "Reptiles" collection mint and metadata + let reptile = Keypair::new(); + let reptile_mint = Keypair::new(); + let reptile_mint_authority = Keypair::new(); + let reptile_update_authority = Keypair::new(); + let reptile_metadata_state = TokenMetadata { + name: "Reptiles".to_string(), + symbol: "RPTL".to_string(), + ..TokenMetadata::default() + }; + setup_mint_and_metadata( + &Token::new( + client.clone(), + &spl_token_2022::id(), + &reptile_mint.pubkey(), + Some(0), + payer.clone(), + ), + &reptile_mint, + &reptile_mint_authority, + &reptile_metadata_state, + payer.clone(), + ) + .await; + + // Set up the "Snakes" collection mint and metadata + let snake = Keypair::new(); + let snake_mint = Keypair::new(); + let snake_mint_authority = Keypair::new(); + let snake_update_authority = Keypair::new(); + let snake_metadata_state = TokenMetadata { + name: "Snakes".to_string(), + symbol: "SNKE".to_string(), + ..TokenMetadata::default() + }; + setup_mint_and_metadata( + &Token::new( + client.clone(), + &spl_token_2022::id(), + &snake_mint.pubkey(), + Some(0), + payer.clone(), + ), + &snake_mint, + &snake_mint_authority, + &snake_metadata_state, + payer.clone(), + ) + .await; + + // Set up the "Python" mint and metadata + let python = Keypair::new(); + let python_mint = Keypair::new(); + let python_mint_authority = Keypair::new(); + let python_metadata_state = TokenMetadata { + name: "Python".to_string(), + symbol: "PYTH".to_string(), + ..TokenMetadata::default() + }; + setup_mint_and_metadata( + &Token::new( + client.clone(), + &spl_token_2022::id(), + &python_mint.pubkey(), + Some(0), + payer.clone(), + ), + &python_mint, + &python_mint_authority, + &python_metadata_state, + payer.clone(), + ) + .await; + + // Set up the "Cobra" mint and metadata + let cobra = Keypair::new(); + let cobra_mint = Keypair::new(); + let cobra_mint_authority = Keypair::new(); + let cobra_metadata_state = TokenMetadata { + name: "Cobra".to_string(), + symbol: "CBRA".to_string(), + ..TokenMetadata::default() + }; + setup_mint_and_metadata( + &Token::new( + client.clone(), + &spl_token_2022::id(), + &cobra_mint.pubkey(), + Some(0), + payer.clone(), + ), + &cobra_mint, + &cobra_mint_authority, + &cobra_metadata_state, + payer.clone(), + ) + .await; + + // Set up the "Iguana" mint and metadata + let iguana = Keypair::new(); + let iguana_mint = Keypair::new(); + let iguana_mint_authority = Keypair::new(); + let iguana_metadata_state = TokenMetadata { + name: "Iguana".to_string(), + symbol: "IGUA".to_string(), + ..TokenMetadata::default() + }; + setup_mint_and_metadata( + &Token::new( + client.clone(), + &spl_token_2022::id(), + &iguana_mint.pubkey(), + Some(0), + payer.clone(), + ), + &iguana_mint, + &iguana_mint_authority, + &iguana_metadata_state, + payer.clone(), + ) + .await; + + let mut context = context.lock().await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let collection_space = TlvStateBorrowed::get_base_len() + std::mem::size_of::(); + let collection_rent_lamports = rent.minimum_balance(collection_space); + let member_space = TlvStateBorrowed::get_base_len() + std::mem::size_of::(); + let member_rent_lamports = rent.minimum_balance(member_space); + + // Create the collections using the SPL Token Collection program + setup_group( + &mut context, + &program_id, + &reptile, + &reptile_mint, + &reptile_mint_authority, + Some(reptile_update_authority.pubkey()), + 3, + collection_rent_lamports, + collection_space, + ) + .await; + setup_group( + &mut context, + &program_id, + &snake, + &snake_mint, + &snake_mint_authority, + Some(snake_update_authority.pubkey()), + 2, + collection_rent_lamports, + collection_space, + ) + .await; + + // Create the member accounts ahead of time + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::create_account( + &context.payer.pubkey(), + &python.pubkey(), + member_rent_lamports.checked_mul(2).unwrap(), // 2 collections + u64::try_from(member_space).unwrap().checked_mul(2).unwrap(), // 2 collections + &program_id, + ), + system_instruction::create_account( + &context.payer.pubkey(), + &cobra.pubkey(), + member_rent_lamports.checked_mul(2).unwrap(), // 2 collections + u64::try_from(member_space).unwrap().checked_mul(2).unwrap(), // 2 collections + &program_id, + ), + system_instruction::create_account( + &context.payer.pubkey(), + &iguana.pubkey(), + member_rent_lamports, + member_space.try_into().unwrap(), + &program_id, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &python, &cobra, &iguana], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // A python is both a reptile and a snake! + let transaction = Transaction::new_signed_with_payer( + &[ + initialize_member( + &program_id, + &python.pubkey(), + &python_mint.pubkey(), + &python_mint_authority.pubkey(), + &reptile.pubkey(), + &reptile_update_authority.pubkey(), + ), + initialize_member( + &program_id, + &python.pubkey(), + &python_mint.pubkey(), + &python_mint_authority.pubkey(), + &snake.pubkey(), + &snake_update_authority.pubkey(), + ), + ], + Some(&context.payer.pubkey()), + &[ + &context.payer, + &python_mint_authority, + &reptile_update_authority, + &snake_update_authority, + ], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // A cobra is both a reptile and a snake! + let transaction = Transaction::new_signed_with_payer( + &[ + initialize_member( + &program_id, + &cobra.pubkey(), + &cobra_mint.pubkey(), + &cobra_mint_authority.pubkey(), + &reptile.pubkey(), + &reptile_update_authority.pubkey(), + ), + initialize_member( + &program_id, + &cobra.pubkey(), + &cobra_mint.pubkey(), + &cobra_mint_authority.pubkey(), + &snake.pubkey(), + &snake_update_authority.pubkey(), + ), + ], + Some(&context.payer.pubkey()), + &[ + &context.payer, + &cobra_mint_authority, + &reptile_update_authority, + &snake_update_authority, + ], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // An iguana is a reptile but not a snake! + let mut transaction = Transaction::new_signed_with_payer( + &[initialize_member( + &program_id, + &iguana.pubkey(), + &iguana_mint.pubkey(), + &iguana_mint_authority.pubkey(), + &reptile.pubkey(), + &reptile_update_authority.pubkey(), + )], + Some(&context.payer.pubkey()), + &[ + &context.payer, + &iguana_mint_authority, + &reptile_update_authority, + ], + context.last_blockhash, + ); + transaction.sign( + &[ + &context.payer, + &iguana_mint_authority, + &reptile_update_authority, + ], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // The "Reptiles" collection should have 3 members + let buffer = context + .banks_client + .get_account(reptile.pubkey()) + .await + .unwrap() + .unwrap() + .data; + let state = TlvStateBorrowed::unpack(&buffer).unwrap(); + let collection = state.get_first_value::().unwrap(); + assert_eq!(u32::from(collection.size), 3); + + // The "Snakes" collection should have 2 members + let buffer = context + .banks_client + .get_account(snake.pubkey()) + .await + .unwrap() + .unwrap() + .data; + let state = TlvStateBorrowed::unpack(&buffer).unwrap(); + let collection = state.get_first_value::().unwrap(); + assert_eq!(u32::from(collection.size), 2); + + // The "Python" should be a member of 2 collections + let buffer = context + .banks_client + .get_account(python.pubkey()) + .await + .unwrap() + .unwrap() + .data; + let state = TlvStateBorrowed::unpack(&buffer).unwrap(); + let membership = state + .get_value_with_repetition::(0) + .unwrap(); + assert_eq!(membership.group, reptile.pubkey(),); + let membership = state + .get_value_with_repetition::(1) + .unwrap(); + assert_eq!(membership.group, snake.pubkey(),); + + // The "Cobra" should be a member of 2 collections + let buffer = context + .banks_client + .get_account(cobra.pubkey()) + .await + .unwrap() + .unwrap() + .data; + let state = TlvStateBorrowed::unpack(&buffer).unwrap(); + let membership = state + .get_value_with_repetition::(0) + .unwrap(); + assert_eq!(membership.group, reptile.pubkey(),); + let membership = state + .get_value_with_repetition::(1) + .unwrap(); + assert_eq!(membership.group, snake.pubkey(),); + + // The "Iguana" should be a member of 1 collection + let buffer = context + .banks_client + .get_account(iguana.pubkey()) + .await + .unwrap() + .unwrap() + .data; + let state = TlvStateBorrowed::unpack(&buffer).unwrap(); + let membership = state.get_first_value::().unwrap(); + assert_eq!(membership.group, reptile.pubkey(),); +}