Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

token collection: create spec program #5614

Merged
merged 2 commits into from
Oct 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/pull-request-token-collection.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions token-collection/README.md
Original file line number Diff line number Diff line change
@@ -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`!
34 changes: 34 additions & 0 deletions token-collection/program/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[package]
name = "spl-token-collection"
version = "0.1.0"
description = "Solana Program Library Token Collection"
authors = ["Solana Labs Maintainers <[email protected]>"]
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"]
23 changes: 23 additions & 0 deletions token-collection/program/src/entrypoint.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
10 changes: 10 additions & 0 deletions token-collection/program/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
162 changes: 162 additions & 0 deletions token-collection/program/src/processor.rs
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading