Repository: solana-labs/solana-program-library
Pull request #5659 (2023-10-24 20:53:34)

transfer-hook-cli: Add a tool for creating account metas
#### Problem

I want to demo transfer hooks at breakpoint, but there's no easy way to create / manage the extra account metas required for transfer hooks.

#### Solution

Create a simple CLI that can do exactly that. It even comes with a test and everything! The parsing of extra account metas was lifted directly from the token-cli, with some small modifications for clap v3. The tests were mostly lifted from the token-upgrade-cli.

joncinque: Oh also, reserved the name: https://crates.io/crates/spl-transfer-hook-cli

Patch:
diff --git a/.github/workflows/pull-request-token-collection.yml b/.github/workflows/pull-request-token-collection.yml
new file mode 100644
index 000000000..6e305446c
--- /dev/null
+++ b/.github/workflows/pull-request-token-collection.yml
@@ -0,0 +1,66 @@
+name: Token Collection Pull Request
+
+on:
+  pull_request:
+    paths:
+    - 'token-collection/**'
+    - 'token/**'
+    - 'token-group/**'
+    - 'token-metadata/**'
+    - 'ci/*-version.sh'
+    - '.github/workflows/pull-request-token-collection.yml'
+  push:
+    branches: [master]
+    paths:
+    - 'token-collection/**'
+    - 'token/**'
+    - 'token-group/**'
+    - 'token-metadata/**'
+    - 'ci/*-version.sh'
+    - '.github/workflows/pull-request-token-collection.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-collection
diff --git a/.github/workflows/pull-request-token.yml b/.github/workflows/pull-request-token.yml
index f01dcbaeb..335aebc04 100644
--- a/.github/workflows/pull-request-token.yml
+++ b/.github/workflows/pull-request-token.yml
@@ -323,3 +323,50 @@ jobs:
         run: |
           BUILD_DEPENDENT_PROGRAMS=1 cargo build --manifest-path ./token/cli/Cargo.toml
           cargo test --manifest-path ./token/cli/Cargo.toml
+
+  cargo-build-test-transfer-hook-cli:
+    runs-on: ubuntu-latest
+    needs: [cargo-test-sbf]
+    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: ~/.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: Download spl-transfer-hook-example program
+        uses: actions/download-artifact@v2
+        with:
+          name: spl-transfer-hook-example
+          path: target/deploy
+
+      - name: Build and test
+        run: |
+          cargo test --manifest-path ./token/transfer-hook/cli/Cargo.toml
diff --git a/Cargo.lock b/Cargo.lock
index 8d61f98e4..99c6a30d0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7341,6 +7341,24 @@ 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-example",
+ "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"
@@ -7529,6 +7547,28 @@ dependencies = [
  "thiserror",
 ]
 
+[[package]]
+name = "spl-transfer-hook-cli"
+version = "0.1.0"
+dependencies = [
+ "clap 3.2.25",
+ "futures-util",
+ "solana-clap-v3-utils",
+ "solana-cli-config",
+ "solana-client",
+ "solana-logger",
+ "solana-remote-wallet",
+ "solana-sdk",
+ "solana-test-validator",
+ "spl-tlv-account-resolution 0.4.0",
+ "spl-token-2022 0.9.0",
+ "spl-token-client",
+ "spl-transfer-hook-interface 0.3.0",
+ "strum 0.25.0",
+ "strum_macros 0.25.3",
+ "tokio",
+]
+
 [[package]]
 name = "spl-transfer-hook-example"
 version = "0.3.0"
diff --git a/Cargo.toml b/Cargo.toml
index f6f1dd683..6f042b7c8 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",
@@ -57,6 +58,7 @@ members = [
   "token/program",
   "token/program-2022",
   "token/program-2022-test",
+  "token/transfer-hook/cli",
   "token/transfer-hook/example",
   "token/transfer-hook/interface",
   "token/client",
diff --git a/token-collection/README.md b/token-collection/README.md
new file mode 100644
index 000000000..0b311156f
--- /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 000000000..33f2c54ee
--- /dev/null
+++ b/token-collection/program/Cargo.toml
@@ -0,0 +1,34 @@
+[package]
+name = "spl-token-collection"
+version = "0.1.0"
+description = "Solana Program Library Token Collection"
+authors = ["Solana Labs Maintainers <maintainers@solanalabs.com>"]
+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-example = { version = "0.1.0", path = "../../token-group/example", 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 000000000..762ec7525
--- /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::<TokenGroupError>();
+        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 000000000..6e539b2fd
--- /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 000000000..c5153a037
--- /dev/null
+++ b/token-collection/program/src/processor.rs
@@ -0,0 +1,162 @@
+//! 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},
+        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::<Pubkey>::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::<Mint>::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::<MetadataPointer>()?;
+    let metadata_pointer_address = Option::<Pubkey>::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::<TokenMetadata>()?;
+    }
+
+    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::<TokenGroup>(false)?;
+    *collection = TokenGroup::new(mint_info.key, data.update_authority, data.max_size.into());
+
+    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::<TokenGroup>()?;
+
+    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::<TokenGroupMember>(/* 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");
+            // Same functionality as the example program
+            spl_token_group_example::processor::process_update_group_max_size(
+                program_id, accounts, data,
+            )
+        }
+        TokenGroupInstruction::UpdateGroupAuthority(data) => {
+            msg!("Instruction: UpdateCollectionAuthority");
+            // Same functionality as the example program
+            spl_token_group_example::processor::process_update_group_authority(
+                program_id, accounts, data,
+            )
+        }
+        TokenGroupInstruction::InitializeMember(_) => {
+            msg!("Instruction: InitializeCollectionMember");
+            process_initialize_collection_member(program_id, accounts)
+        }
+    }
+}
diff --git a/token/transfer-hook/cli/Cargo.toml b/token/transfer-hook/cli/Cargo.toml
new file mode 100644
index 000000000..b9f155024
--- /dev/null
+++ b/token/transfer-hook/cli/Cargo.toml
@@ -0,0 +1,33 @@
+[package]
+authors = ["Solana Labs Maintainers <maintainers@solanalabs.com>"]
+description = "SPL-Token Command-line Utility"
+edition = "2021"
+homepage = "https://spl.solana.com/token"
+license = "Apache-2.0"
+name = "spl-transfer-hook-cli"
+repository = "https://github.com/solana-labs/solana-program-library"
+version = "0.1.0"
+
+[dependencies]
+clap = { version = "3", features = ["cargo"] }
+futures-util = "0.3.19"
+solana-clap-v3-utils = "=1.17.2"
+solana-cli-config = "=1.17.2"
+solana-client = "=1.17.2"
+solana-logger = "=1.17.2"
+solana-remote-wallet = "=1.17.2"
+solana-sdk = "=1.17.2"
+spl-transfer-hook-interface = { version = "0.3", path = "../interface" }
+spl-tlv-account-resolution = { version = "0.4" , path = "../../../libraries/tlv-account-resolution" }
+strum = "0.25"
+strum_macros = "0.25"
+tokio = { version = "1", features = ["full"] }
+
+[dev-dependencies]
+solana-test-validator = "=1.17.2"
+spl-token-2022 = { version = "0.9", path = "../../program-2022", features = ["no-entrypoint"] }
+spl-token-client = { version = "0.7", path = "../../client" }
+
+[[bin]]
+name = "spl-transfer-hook"
+path = "src/main.rs"
diff --git a/token/transfer-hook/cli/src/main.rs b/token/transfer-hook/cli/src/main.rs
new file mode 100644
index 000000000..63d11f890
--- /dev/null
+++ b/token/transfer-hook/cli/src/main.rs
@@ -0,0 +1,407 @@
+use {
+    clap::{crate_description, crate_name, crate_version, Arg, Command},
+    solana_clap_v3_utils::{
+        input_parsers::{parse_url_or_moniker, pubkey_of_signer},
+        input_validators::{is_valid_pubkey, is_valid_signer, normalize_to_url_if_moniker},
+        keypair::DefaultSigner,
+    },
+    solana_client::nonblocking::rpc_client::RpcClient,
+    solana_remote_wallet::remote_wallet::RemoteWalletManager,
+    solana_sdk::{
+        commitment_config::CommitmentConfig,
+        instruction::AccountMeta,
+        pubkey::Pubkey,
+        signature::{Signature, Signer},
+        system_instruction, system_program,
+        transaction::Transaction,
+    },
+    spl_tlv_account_resolution::state::ExtraAccountMetaList,
+    spl_transfer_hook_interface::{
+        get_extra_account_metas_address, instruction::initialize_extra_account_meta_list,
+    },
+    std::{fmt, process::exit, rc::Rc, str::FromStr},
+    strum_macros::{EnumString, IntoStaticStr},
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, EnumString, IntoStaticStr)]
+#[strum(serialize_all = "kebab-case")]
+pub enum AccountMetaRole {
+    Readonly,
+    Writable,
+    ReadonlySigner,
+    WritableSigner,
+}
+impl fmt::Display for AccountMetaRole {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(f, "{:?}", self)
+    }
+}
+fn parse_transfer_hook_account(arg: &str) -> Result<AccountMeta, String> {
+    match arg.split(':').collect::<Vec<_>>().as_slice() {
+        [address, role] => {
+            let address = Pubkey::from_str(address).map_err(|e| format!("{e}"))?;
+            let meta = match AccountMetaRole::from_str(role).map_err(|e| format!("{e}"))? {
+                AccountMetaRole::Readonly => AccountMeta::new_readonly(address, false),
+                AccountMetaRole::Writable => AccountMeta::new(address, false),
+                AccountMetaRole::ReadonlySigner => AccountMeta::new_readonly(address, true),
+                AccountMetaRole::WritableSigner => AccountMeta::new(address, true),
+            };
+            Ok(meta)
+        }
+        _ => Err("Transfer hook account must be present as <ADDRESS>:<ROLE>".to_string()),
+    }
+}
+
+fn clap_is_valid_pubkey(arg: &str) -> Result<(), String> {
+    is_valid_pubkey(arg)
+}
+
+struct Config {
+    commitment_config: CommitmentConfig,
+    default_signer: Box<dyn Signer>,
+    json_rpc_url: String,
+    verbose: bool,
+}
+
+async fn process_create_extra_account_metas(
+    rpc_client: &RpcClient,
+    program_id: &Pubkey,
+    token: &Pubkey,
+    transfer_hook_accounts: Vec<AccountMeta>,
+    mint_authority: &dyn Signer,
+    payer: &dyn Signer,
+) -> Result<Signature, Box<dyn std::error::Error>> {
+    let extra_account_metas_address = get_extra_account_metas_address(token, program_id);
+    let extra_account_metas = transfer_hook_accounts
+        .into_iter()
+        .map(|v| v.into())
+        .collect::<Vec<_>>();
+
+    let length = extra_account_metas.len();
+    let account_size = ExtraAccountMetaList::size_of(length)?;
+    let required_lamports = rpc_client
+        .get_minimum_balance_for_rent_exemption(account_size)
+        .await
+        .map_err(|err| format!("error: unable to fetch rent-exemption: {err}"))?;
+    let extra_account_metas_account = rpc_client.get_account(&extra_account_metas_address).await;
+    if let Ok(account) = &extra_account_metas_account {
+        if account.owner != system_program::id() {
+            return Err(format!("error: extra account metas for mint {token} and program {program_id} already exists").into());
+        }
+    }
+    let current_lamports = extra_account_metas_account.map(|a| a.lamports).unwrap_or(0);
+    let transfer_lamports = required_lamports.saturating_sub(current_lamports);
+
+    let mut ixs = vec![];
+    if transfer_lamports > 0 {
+        ixs.push(system_instruction::transfer(
+            &payer.pubkey(),
+            &extra_account_metas_address,
+            transfer_lamports,
+        ));
+    }
+    ixs.push(initialize_extra_account_meta_list(
+        program_id,
+        &extra_account_metas_address,
+        token,
+        &mint_authority.pubkey(),
+        &extra_account_metas,
+    ));
+
+    let mut transaction = Transaction::new_with_payer(&ixs, Some(&payer.pubkey()));
+    let blockhash = rpc_client
+        .get_latest_blockhash()
+        .await
+        .map_err(|err| format!("error: unable to get latest blockhash: {err}"))?;
+    let mut signers = vec![payer];
+    if payer.pubkey() != mint_authority.pubkey() {
+        signers.push(mint_authority);
+    }
+    transaction
+        .try_sign(&signers, blockhash)
+        .map_err(|err| format!("error: failed to sign transaction: {err}"))?;
+
+    rpc_client
+        .send_and_confirm_transaction_with_spinner(&transaction)
+        .await
+        .map_err(|err| format!("error: send transaction: {err}").into())
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let app_matches = Command::new(crate_name!())
+        .about(crate_description!())
+        .version(crate_version!())
+        .subcommand_required(true)
+        .arg_required_else_help(true)
+        .arg({
+            let arg = Arg::new("config_file")
+                .short('C')
+                .long("config")
+                .value_name("PATH")
+                .takes_value(true)
+                .global(true)
+                .help("Configuration file to use");
+            if let Some(ref config_file) = *solana_cli_config::CONFIG_FILE {
+                arg.default_value(config_file)
+            } else {
+                arg
+            }
+        })
+        .arg(
+            Arg::new("fee_payer")
+                .long("fee-payer")
+                .value_name("KEYPAIR")
+                .validator(|s| is_valid_signer(s))
+                .takes_value(true)
+                .global(true)
+                .help("Filepath or URL to a keypair to pay transaction fee [default: client keypair]"),
+        )
+        .arg(
+            Arg::new("verbose")
+                .long("verbose")
+                .short('v')
+                .takes_value(false)
+                .global(true)
+                .help("Show additional information"),
+        )
+        .arg(
+            Arg::new("json_rpc_url")
+                .short('u')
+                .long("url")
+                .value_name("URL")
+                .takes_value(true)
+                .global(true)
+                .value_parser(parse_url_or_moniker)
+                .help("JSON RPC URL for the cluster [default: value from configuration file]"),
+        )
+        .subcommand(
+            Command::new("create-extra-metas")
+                .about("Create the extra account metas account for a transfer hook program")
+                .arg(
+                    Arg::with_name("program_id")
+                        .validator(clap_is_valid_pubkey)
+                        .value_name("TRANSFER_HOOK_PROGRAM")
+                        .takes_value(true)
+                        .index(1)
+                        .required(true)
+                        .help("The transfer hook program id"),
+                )
+                .arg(
+                    Arg::with_name("token")
+                        .validator(clap_is_valid_pubkey)
+                        .value_name("TOKEN_MINT_ADDRESS")
+                        .takes_value(true)
+                        .index(2)
+                        .required(true)
+                        .help("The token mint address for the transfer hook"),
+                )
+                .arg(
+                    Arg::with_name("transfer_hook_account")
+                        .value_parser(parse_transfer_hook_account)
+                        .value_name("PUBKEY:ROLE")
+                        .takes_value(true)
+                        .multiple(true)
+                        .min_values(0)
+                        .index(3)
+                        .help("Additional pubkey(s) required for a transfer hook and their \
+                            role, in the format \"<PUBKEY>:<ROLE>\". The role must be \
+                            \"readonly\", \"writable\". \"readonly-signer\", or \"writable-signer\".")
+                )
+                .arg(
+                    Arg::new("mint_authority")
+                        .long("mint-authority")
+                        .value_name("KEYPAIR")
+                        .validator(|s| is_valid_signer(s))
+                        .takes_value(true)
+                        .global(true)
+                        .help("Filepath or URL to mint-authority keypair [default: client keypair]"),
+                )
+        ).get_matches();
+
+    let (command, matches) = app_matches.subcommand().unwrap();
+    let mut wallet_manager: Option<Rc<RemoteWalletManager>> = None;
+
+    let cli_config = if let Some(config_file) = matches.value_of("config_file") {
+        solana_cli_config::Config::load(config_file).unwrap_or_default()
+    } else {
+        solana_cli_config::Config::default()
+    };
+
+    let config = {
+        let default_signer = DefaultSigner::new(
+            "fee_payer",
+            matches
+                .value_of("fee_payer")
+                .map(|s| s.to_string())
+                .unwrap_or_else(|| cli_config.keypair_path.clone()),
+        );
+
+        let json_rpc_url = normalize_to_url_if_moniker(
+            matches
+                .value_of("json_rpc_url")
+                .unwrap_or(&cli_config.json_rpc_url),
+        );
+
+        Config {
+            commitment_config: CommitmentConfig::confirmed(),
+            default_signer: default_signer
+                .signer_from_path(matches, &mut wallet_manager)
+                .unwrap_or_else(|err| {
+                    eprintln!("error: {err}");
+                    exit(1);
+                }),
+            json_rpc_url,
+            verbose: matches.is_present("verbose"),
+        }
+    };
+    solana_logger::setup_with_default("solana=info");
+
+    if config.verbose {
+        println!("JSON RPC URL: {}", config.json_rpc_url);
+    }
+    let rpc_client =
+        RpcClient::new_with_commitment(config.json_rpc_url.clone(), config.commitment_config);
+
+    match (command, matches) {
+        ("create-extra-metas", arg_matches) => {
+            let program_id = pubkey_of_signer(arg_matches, "program_id", &mut wallet_manager)
+                .unwrap()
+                .unwrap();
+            let token = pubkey_of_signer(arg_matches, "token", &mut wallet_manager)
+                .unwrap()
+                .unwrap();
+            let transfer_hook_accounts = arg_matches
+                .get_many::<AccountMeta>("transfer_hook_account")
+                .unwrap_or_default()
+                .cloned()
+                .collect();
+            let mint_authority = DefaultSigner::new(
+                "mint_authority",
+                matches
+                    .value_of("mint_authority")
+                    .map(|s| s.to_string())
+                    .unwrap_or_else(|| cli_config.keypair_path.clone()),
+            )
+            .signer_from_path(matches, &mut wallet_manager)
+            .unwrap_or_else(|err| {
+                eprintln!("error: {err}");
+                exit(1);
+            });
+            let signature = process_create_extra_account_metas(
+                &rpc_client,
+                &program_id,
+                &token,
+                transfer_hook_accounts,
+                mint_authority.as_ref(),
+                config.default_signer.as_ref(),
+            )
+            .await
+            .unwrap_or_else(|err| {
+                eprintln!("error: send transaction: {err}");
+                exit(1);
+            });
+            println!("Signature: {signature}");
+        }
+        _ => unreachable!(),
+    };
+
+    Ok(())
+}
+
+#[cfg(test)]
+mod test {
+    use {
+        super::*,
+        solana_sdk::{bpf_loader_upgradeable, signer::keypair::Keypair},
+        solana_test_validator::{TestValidator, TestValidatorGenesis, UpgradeableProgramInfo},
+        spl_token_client::{
+            client::{
+                ProgramClient, ProgramRpcClient, ProgramRpcClientSendTransaction, SendTransaction,
+                SimulateTransaction,
+            },
+            token::Token,
+        },
+        std::{path::PathBuf, sync::Arc},
+    };
+
+    async fn new_validator_for_test(program_id: Pubkey) -> (TestValidator, Keypair) {
+        solana_logger::setup();
+        let mut test_validator_genesis = TestValidatorGenesis::default();
+        test_validator_genesis.add_upgradeable_programs_with_path(&[UpgradeableProgramInfo {
+            program_id,
+            loader: bpf_loader_upgradeable::id(),
+            program_path: PathBuf::from("../../../target/deploy/spl_transfer_hook_example.so"),
+            upgrade_authority: Pubkey::new_unique(),
+        }]);
+        test_validator_genesis.start_async().await
+    }
+
+    async fn setup_mint<T: SendTransaction + SimulateTransaction>(
+        program_id: &Pubkey,
+        mint_authority: &Pubkey,
+        decimals: u8,
+        payer: Arc<dyn Signer>,
+        client: Arc<dyn ProgramClient<T>>,
+    ) -> Token<T> {
+        let mint_account = Keypair::new();
+        let token = Token::new(
+            client,
+            program_id,
+            &mint_account.pubkey(),
+            Some(decimals),
+            payer,
+        );
+        token
+            .create_mint(mint_authority, None, vec![], &[&mint_account])
+            .await
+            .unwrap();
+        token
+    }
+
+    #[tokio::test]
+    async fn test_create() {
+        let program_id = Pubkey::new_unique();
+
+        let (test_validator, payer) = new_validator_for_test(program_id).await;
+        let payer: Arc<dyn Signer> = Arc::new(payer);
+        let rpc_client = Arc::new(test_validator.get_async_rpc_client());
+        let client = Arc::new(ProgramRpcClient::new(
+            rpc_client.clone(),
+            ProgramRpcClientSendTransaction,
+        ));
+
+        let mint_authority = Keypair::new();
+        let decimals = 2;
+
+        let token = setup_mint(
+            &spl_token_2022::id(),
+            &mint_authority.pubkey(),
+            decimals,
+            payer.clone(),
+            client.clone(),
+        )
+        .await;
+
+        let required_address = Pubkey::new_unique();
+        let accounts = vec![AccountMeta::new_readonly(required_address, false)];
+        process_create_extra_account_metas(
+            &rpc_client,
+            &program_id,
+            token.get_address(),
+            accounts,
+            &mint_authority,
+            payer.as_ref(),
+        )
+        .await
+        .unwrap();
+
+        let extra_account_metas_address =
+            get_extra_account_metas_address(token.get_address(), &program_id);
+        let account = rpc_client
+            .get_account(&extra_account_metas_address)
+            .await
+            .unwrap();
+        assert_eq!(account.owner, program_id);
+    }
+}


Test patch:
diff --git a/ci/cargo-build-test.sh b/ci/cargo-build-test.sh
index 3f4bcb2a8..fa930f4e9 100755
--- a/ci/cargo-build-test.sh
+++ b/ci/cargo-build-test.sh
@@ -15,8 +15,8 @@ set -x
 make -C examples/c
 
 # Build/test all host crates
-cargo +"$rust_stable" build --workspace --exclude spl-token-cli --exclude spl-token-upgrade-cli --exclude spl-single-pool-cli
-cargo +"$rust_stable" test --workspace --exclude spl-token-cli --exclude spl-token-upgrade-cli --exclude spl-single-pool-cli -- --nocapture
+cargo +"$rust_stable" build --workspace --exclude spl-token-cli --exclude spl-token-upgrade-cli --exclude spl-single-pool-cli --exclude spl-transfer-hook-cli
+cargo +"$rust_stable" test --workspace --exclude spl-token-cli --exclude spl-token-upgrade-cli --exclude spl-single-pool-cli --exclude spl-transfer-hook-cli -- --nocapture
 
 # Run test-client sanity check
 cargo +"$rust_stable" run --manifest-path=utils/test-client/Cargo.toml
diff --git a/token-collection/program/tests/setup.rs b/token-collection/program/tests/setup.rs
new file mode 100644
index 000000000..709f6f929
--- /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<Mutex<ProgramTestContext>>,
+    Arc<dyn ProgramClient<ProgramBanksClientProcessTransaction>>,
+    Arc<Keypair>,
+) {
+    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<dyn ProgramClient<ProgramBanksClientProcessTransaction>> =
+        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<T: SendTransaction + SimulateTransaction>(
+    token_client: &Token<T>,
+    mint_keypair: &Keypair,
+    mint_authority_keypair: &Keypair,
+    token_metadata: &TokenMetadata,
+    payer: Arc<Keypair>,
+) {
+    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<Pubkey>,
+    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 000000000..aeff8994e
--- /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::<TokenGroup>();
+    let collection_rent_lamports = rent.minimum_balance(collection_space);
+    let member_space = TlvStateBorrowed::get_base_len() + std::mem::size_of::<TokenGroupMember>();
+    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::<TokenGroup>().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::<TokenGroup>().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::<TokenGroupMember>(0)
+        .unwrap();
+    assert_eq!(membership.group, reptile.pubkey(),);
+    let membership = state
+        .get_value_with_repetition::<TokenGroupMember>(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::<TokenGroupMember>(0)
+        .unwrap();
+    assert_eq!(membership.group, reptile.pubkey(),);
+    let membership = state
+        .get_value_with_repetition::<TokenGroupMember>(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::<TokenGroupMember>().unwrap();
+    assert_eq!(membership.group, reptile.pubkey(),);
+}