diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 20e98b86..3e391fe0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - library: [discriminator, pod, program-error, tlv-account-resolution, type-length-value, type-length-value-derive-test] + library: [discriminator, generic-token, generic-token-tests, pod, program-error, tlv-account-resolution, type-length-value, type-length-value-derive-test] steps: - name: Git Checkout uses: actions/checkout@v4 @@ -93,7 +93,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - library: [discriminator, pod, program-error, tlv-account-resolution, type-length-value, type-length-value-derive-test] + library: [discriminator, generic-token, pod, program-error, tlv-account-resolution, type-length-value, type-length-value-derive-test] steps: - name: Git Checkout uses: actions/checkout@v4 @@ -134,7 +134,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - library: [discriminator, pod, program-error, tlv-account-resolution, type-length-value, type-length-value-derive-test] + library: [discriminator, generic-token, generic-token-tests, pod, program-error, tlv-account-resolution, type-length-value, type-length-value-derive-test] steps: - name: Git Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/publish-rust.yml b/.github/workflows/publish-rust.yml index 22dd8a53..27016e00 100644 --- a/.github/workflows/publish-rust.yml +++ b/.github/workflows/publish-rust.yml @@ -12,6 +12,7 @@ on: - discriminator - discriminator/derive - discriminator/syn + - generic-token - pod - program-error - program-error/derive diff --git a/Cargo.lock b/Cargo.lock index 85308d85..e0508433 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5669,6 +5669,12 @@ dependencies = [ "solana-sdk-ids", ] +[[package]] +name = "solana-security-txt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "468aa43b7edb1f9b7b7b686d5c3aeb6630dc1708e86e31343499dd5c4d775183" + [[package]] name = "solana-seed-derivable" version = "2.2.1" @@ -6552,22 +6558,58 @@ dependencies = [ "bytemuck", "solana-program-error", "solana-sha256-hasher", - "spl-discriminator", - "spl-discriminator-derive", + "spl-discriminator 0.4.1", + "spl-discriminator-derive 0.2.0", +] + +[[package]] +name = "spl-discriminator" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7398da23554a31660f17718164e31d31900956054f54f52d5ec1be51cb4f4b3" +dependencies = [ + "bytemuck", + "solana-program-error", + "solana-sha256-hasher", + "spl-discriminator-derive 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "spl-discriminator-derive" +version = "0.2.0" +dependencies = [ + "quote", + "spl-discriminator-syn 0.2.0", + "syn 2.0.90", ] [[package]] name = "spl-discriminator-derive" version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9e8418ea6269dcfb01c712f0444d2c75542c04448b480e87de59d2865edc750" +dependencies = [ + "quote", + "spl-discriminator-syn 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 2.0.90", +] + +[[package]] +name = "spl-discriminator-syn" +version = "0.2.0" dependencies = [ + "proc-macro2", "quote", - "spl-discriminator-syn", + "sha2 0.10.8", "syn 2.0.90", + "thiserror 1.0.69", ] [[package]] name = "spl-discriminator-syn" version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c1f05593b7ca9eac7caca309720f2eafb96355e037e6d373b909a80fe7b69b9" dependencies = [ "proc-macro2", "quote", @@ -6576,6 +6618,63 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "spl-elgamal-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65edfeed09cd4231e595616aa96022214f9c9d2be02dea62c2b30d5695a6833a" +dependencies = [ + "bytemuck", + "solana-account-info", + "solana-cpi", + "solana-instruction", + "solana-msg", + "solana-program-entrypoint", + "solana-program-error", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", + "solana-system-interface", + "solana-sysvar", + "solana-zk-sdk", + "spl-pod 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-token-confidential-transfer-proof-extraction", +] + +[[package]] +name = "spl-generic-token" +version = "0.0.0" +dependencies = [ + "bytemuck", + "solana-pubkey", +] + +[[package]] +name = "spl-generic-token-tests" +version = "0.0.0" +dependencies = [ + "rand 0.8.5", + "solana-pubkey", + "spl-generic-token", + "spl-token", + "spl-token-2022", + "test-case", +] + +[[package]] +name = "spl-memo" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f09647c0974e33366efeb83b8e2daebb329f0420149e74d3a4bd2c08cf9f7cb" +dependencies = [ + "solana-account-info", + "solana-instruction", + "solana-msg", + "solana-program-entrypoint", + "solana-program-error", + "solana-pubkey", +] + [[package]] name = "spl-pod" version = "0.5.1" @@ -6597,6 +6696,26 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "spl-pod" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d994afaf86b779104b4a95ba9ca75b8ced3fdb17ee934e38cb69e72afbe17799" +dependencies = [ + "borsh 1.5.7", + "bytemuck", + "bytemuck_derive", + "num-derive", + "num-traits", + "solana-decode-error", + "solana-msg", + "solana-program-error", + "solana-program-option", + "solana-pubkey", + "solana-zk-sdk", + "thiserror 2.0.12", +] + [[package]] name = "spl-program-error" version = "0.7.0" @@ -6610,13 +6729,40 @@ dependencies = [ "solana-program-error", "solana-sha256-hasher", "solana-sysvar", - "spl-program-error-derive", + "spl-program-error-derive 0.5.0", "thiserror 2.0.12", ] +[[package]] +name = "spl-program-error" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdebc8b42553070b75aa5106f071fef2eb798c64a7ec63375da4b1f058688c6" +dependencies = [ + "num-derive", + "num-traits", + "solana-decode-error", + "solana-msg", + "solana-program-error", + "spl-program-error-derive 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "thiserror 2.0.12", +] + +[[package]] +name = "spl-program-error-derive" +version = "0.5.0" +dependencies = [ + "proc-macro2", + "quote", + "sha2 0.10.8", + "syn 2.0.90", +] + [[package]] name = "spl-program-error-derive" version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2539e259c66910d78593475540e8072f0b10f0f61d7607bbf7593899ed52d0" dependencies = [ "proc-macro2", "quote", @@ -6643,10 +6789,212 @@ dependencies = [ "solana-program-test", "solana-pubkey", "solana-sdk", - "spl-discriminator", - "spl-pod", - "spl-program-error", - "spl-type-length-value", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", + "spl-program-error 0.7.0", + "spl-type-length-value 0.8.0", + "thiserror 2.0.12", +] + +[[package]] +name = "spl-tlv-account-resolution" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1408e961215688715d5a1063cbdcf982de225c45f99c82b4f7d7e1dd22b998d7" +dependencies = [ + "bytemuck", + "num-derive", + "num-traits", + "solana-account-info", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-program-error", + "solana-pubkey", + "spl-discriminator 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-pod 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-program-error 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-type-length-value 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", + "thiserror 2.0.12", +] + +[[package]] +name = "spl-token" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053067c6a82c705004f91dae058b11b4780407e9ccd6799dc9e7d0fab5f242da" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-account-info", + "solana-cpi", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-program-option", + "solana-program-pack", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", + "solana-sysvar", + "thiserror 2.0.12", +] + +[[package]] +name = "spl-token-2022" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f0dfbb079eebaee55e793e92ca5f433744f4b71ee04880bfd6beefba5973e5" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-account-info", + "solana-clock", + "solana-cpi", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-native-token", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-program-option", + "solana-program-pack", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", + "solana-security-txt", + "solana-system-interface", + "solana-sysvar", + "solana-zk-sdk", + "spl-elgamal-registry", + "spl-memo", + "spl-pod 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-token", + "spl-token-confidential-transfer-ciphertext-arithmetic", + "spl-token-confidential-transfer-proof-extraction", + "spl-token-confidential-transfer-proof-generation", + "spl-token-group-interface", + "spl-token-metadata-interface", + "spl-transfer-hook-interface", + "spl-type-length-value 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", + "thiserror 2.0.12", +] + +[[package]] +name = "spl-token-confidential-transfer-ciphertext-arithmetic" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94ab20faf7b5edaa79acd240e0f21d5a2ef936aa99ed98f698573a2825b299c4" +dependencies = [ + "base64 0.22.1", + "bytemuck", + "solana-curve25519", + "solana-zk-sdk", +] + +[[package]] +name = "spl-token-confidential-transfer-proof-extraction" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2629860ff04c17bafa9ba4bed8850a404ecac81074113e1f840dbd0ebb7bd6" +dependencies = [ + "bytemuck", + "solana-account-info", + "solana-curve25519", + "solana-instruction", + "solana-instructions-sysvar", + "solana-msg", + "solana-program-error", + "solana-pubkey", + "solana-sdk-ids", + "solana-zk-sdk", + "spl-pod 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", + "thiserror 2.0.12", +] + +[[package]] +name = "spl-token-confidential-transfer-proof-generation" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5b124840d4aed474cef101d946a798b806b46a509ee4df91021e1ab1cef3ef" +dependencies = [ + "curve25519-dalek 4.1.3", + "solana-zk-sdk", + "thiserror 2.0.12", +] + +[[package]] +name = "spl-token-group-interface" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5597b4cd76f85ce7cd206045b7dc22da8c25516573d42d267c8d1fd128db5129" +dependencies = [ + "bytemuck", + "num-derive", + "num-traits", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-program-error", + "solana-pubkey", + "spl-discriminator 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-pod 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", + "thiserror 2.0.12", +] + +[[package]] +name = "spl-token-metadata-interface" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "304d6e06f0de0c13a621464b1fd5d4b1bebf60d15ca71a44d3839958e0da16ee" +dependencies = [ + "borsh 1.5.7", + "num-derive", + "num-traits", + "solana-borsh", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-program-error", + "solana-pubkey", + "spl-discriminator 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-pod 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-type-length-value 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", + "thiserror 2.0.12", +] + +[[package]] +name = "spl-transfer-hook-interface" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e905b849b6aba63bde8c4badac944ebb6c8e6e14817029cbe1bc16829133bd" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "solana-account-info", + "solana-cpi", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-program-error", + "solana-pubkey", + "spl-discriminator 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-pod 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-program-error 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-tlv-account-resolution 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-type-length-value 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", "thiserror 2.0.12", ] @@ -6661,12 +7009,30 @@ dependencies = [ "solana-decode-error", "solana-msg", "solana-program-error", - "spl-discriminator", - "spl-pod", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", "spl-type-length-value-derive", "thiserror 2.0.12", ] +[[package]] +name = "spl-type-length-value" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d417eb548214fa822d93f84444024b4e57c13ed6719d4dcc68eec24fb481e9f5" +dependencies = [ + "bytemuck", + "num-derive", + "num-traits", + "solana-account-info", + "solana-decode-error", + "solana-msg", + "solana-program-error", + "spl-discriminator 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-pod 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", + "thiserror 2.0.12", +] + [[package]] name = "spl-type-length-value-derive" version = "0.2.0" @@ -6682,8 +7048,8 @@ version = "0.1.0" dependencies = [ "borsh 1.5.7", "solana-borsh", - "spl-discriminator", - "spl-type-length-value", + "spl-discriminator 0.4.1", + "spl-type-length-value 0.8.0", ] [[package]] @@ -6894,6 +7260,39 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", + "test-case-core", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index a23ddd6f..3d142224 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ resolver = "2" members = [ "discriminator", + "generic-token", + "generic-token-tests", "pod", "program-error", "tlv-account-resolution", diff --git a/README.md b/README.md index 44bb370b..17eff5bf 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ # libraries -Helper libraries for Solana on-chain programs +Helper libraries for Solana on-chain programs and for dealing with SPL Token types diff --git a/generic-token-tests/Cargo.toml b/generic-token-tests/Cargo.toml new file mode 100644 index 00000000..679cea7a --- /dev/null +++ b/generic-token-tests/Cargo.toml @@ -0,0 +1,25 @@ +# this package prevents a (future) circular dependency between spl_generic_token and spl_token +# it also makes it convenient for us to use `solana_pubkey::new_rand()` + +[package] +name = "spl-generic-token-tests" +publish = false +version = "0.0.0" +description = "Solana Program Library Generic Token Tests" +authors = ["Anza Maintainers "] +repository = "https://github.com/solana-program/libraries" +license = "Apache-2.0" +edition = "2021" + +[dev-dependencies] +rand = "0.8.0" +spl-generic-token = { path = "../generic-token" } +spl-token = "8.0.0" +spl-token-2022 = "8.0.0" +solana-pubkey = { version = "2.2.1", features = [ + "rand", +] } +test-case = "3.3.1" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/generic-token-tests/tests/test_generic_token.rs b/generic-token-tests/tests/test_generic_token.rs new file mode 100644 index 00000000..19833af2 --- /dev/null +++ b/generic-token-tests/tests/test_generic_token.rs @@ -0,0 +1,242 @@ +use { + rand::prelude::*, + spl_generic_token::{generic_token, token, token_2022}, + spl_token::{ + solana_program::program_pack::Pack, + state::{Account as SplAccount, AccountState as SplAccountState, Mint as SplMint}, + }, + spl_token_2022::{ + extension::set_account_type, + state::{Account as SplAccount2022, Mint as SplMint2022, Multisig as SplMultisig}, + }, + test_case::test_case, +}; + +#[test] +fn test_get_packed_len() { + assert_eq!(token::Account::get_packed_len(), SplAccount::LEN); + assert_eq!(token::Mint::get_packed_len(), SplMint::LEN); +} + +fn random_token_account() -> SplAccount { + let mut rng = thread_rng(); + + let mint = solana_pubkey::new_rand(); + let owner = solana_pubkey::new_rand(); + let amount = rng.gen(); + let delegate = if rng.gen() { + Some(solana_pubkey::new_rand()) + } else { + None + } + .into(); + let state = rng.gen_range(0..3).try_into().unwrap(); + let is_native = rng.gen::>().into(); + let delegated_amount = rng.gen(); + let close_authority = if rng.gen() { + Some(solana_pubkey::new_rand()) + } else { + None + } + .into(); + + SplAccount { + mint, + owner, + amount, + delegate, + state, + is_native, + delegated_amount, + close_authority, + } +} + +#[test_case(false; "spl_token")] +#[test_case(true; "spl_token_2022")] +fn test_generic_account(is_token_2022_account: bool) { + for _ in 0..1000 { + let expected_account = random_token_account(); + let is_initialized = expected_account.state != SplAccountState::Uninitialized; + + let mut account_data = vec![0; SplAccount::LEN]; + expected_account.pack_into_slice(&mut account_data); + + // check the basic rules of the parser: + // * uninitialized accounts never parse + // * standard token accounts parse as both + // * typed 2022 accounts parse only as 2022 + if is_initialized && is_token_2022_account { + account_data.resize(SplAccount::LEN + 2, 0); + set_account_type::(&mut account_data).unwrap(); + + // token + assert_eq!( + generic_token::Account::unpack(&account_data, &token::id()), + None + ); + + // token22 + let test_account = + generic_token::Account::unpack(&account_data, &token_2022::id()).unwrap(); + + assert_eq!(test_account.mint, expected_account.mint); + assert_eq!(test_account.owner, expected_account.owner); + assert_eq!(test_account.amount, expected_account.amount); + } else if is_initialized { + // token + let test_account = generic_token::Account::unpack(&account_data, &token::id()).unwrap(); + + assert_eq!(test_account.mint, expected_account.mint); + assert_eq!(test_account.owner, expected_account.owner); + assert_eq!(test_account.amount, expected_account.amount); + + // token22 + let test_account = + generic_token::Account::unpack(&account_data, &token_2022::id()).unwrap(); + + assert_eq!(test_account.mint, expected_account.mint); + assert_eq!(test_account.owner, expected_account.owner); + assert_eq!(test_account.amount, expected_account.amount); + } else { + // token + assert_eq!( + generic_token::Account::unpack(&account_data, &token::id()), + None + ); + + // token22 + assert_eq!( + generic_token::Account::unpack(&account_data, &token_2022::id()), + None + ); + } + + // a token account should never parse as a mint + assert_eq!( + generic_token::Mint::unpack(&account_data, &token::id()), + None + ); + assert_eq!( + generic_token::Mint::unpack(&account_data, &token_2022::id()), + None + ); + + // an otherwise valid token account should never parse if it is of multisig length + account_data.resize(SplMultisig::LEN, 0); + assert_eq!( + generic_token::Account::unpack(&account_data, &token::id()), + None + ); + assert_eq!( + generic_token::Account::unpack(&account_data, &token_2022::id()), + None + ); + } +} + +fn random_mint() -> SplMint { + let mut rng = thread_rng(); + + let mint_authority = if rng.gen() { + Some(solana_pubkey::new_rand()) + } else { + None + } + .into(); + let supply = rng.gen(); + let decimals = rng.gen(); + let is_initialized = rng.gen(); + let freeze_authority = if rng.gen() { + Some(solana_pubkey::new_rand()) + } else { + None + } + .into(); + + SplMint { + mint_authority, + supply, + decimals, + is_initialized, + freeze_authority, + } +} + +#[test_case(false; "spl_token")] +#[test_case(true; "spl_token_2022")] +fn test_generic_mint(is_token_2022_mint: bool) { + for _ in 0..1000 { + let expected_mint = random_mint(); + let is_initialized = expected_mint.is_initialized; + + let mut account_data = vec![0; SplMint::LEN]; + expected_mint.pack_into_slice(&mut account_data); + + // check the basic rules of the parser: + // * uninitialized mints never parse + // * standard token mints parse as both + // * typed 2022 mints parse only as 2022 + if is_initialized && is_token_2022_mint { + account_data.resize(SplAccount::LEN + 2, 0); + set_account_type::(&mut account_data).unwrap(); + + // token + assert_eq!( + generic_token::Mint::unpack(&account_data, &token::id()), + None + ); + + // token22 + let test_mint = generic_token::Mint::unpack(&account_data, &token_2022::id()).unwrap(); + + assert_eq!(test_mint.supply, expected_mint.supply); + assert_eq!(test_mint.decimals, expected_mint.decimals); + } else if is_initialized { + // token + let test_mint = generic_token::Mint::unpack(&account_data, &token::id()).unwrap(); + + assert_eq!(test_mint.supply, expected_mint.supply); + assert_eq!(test_mint.decimals, expected_mint.decimals); + + // token22 + let test_mint = generic_token::Mint::unpack(&account_data, &token_2022::id()).unwrap(); + + assert_eq!(test_mint.supply, expected_mint.supply); + assert_eq!(test_mint.decimals, expected_mint.decimals); + } else { + // token + assert_eq!( + generic_token::Mint::unpack(&account_data, &token::id()), + None + ); + + // token22 + assert_eq!( + generic_token::Mint::unpack(&account_data, &token_2022::id()), + None + ); + } + + // a mint should never parse as a token account + assert_eq!( + generic_token::Account::unpack(&account_data, &token::id()), + None + ); + assert_eq!( + generic_token::Account::unpack(&account_data, &token_2022::id()), + None + ); + + // an otherwise valid mint should never parse if it is of multisig length + account_data.resize(SplMultisig::LEN, 0); + assert_eq!( + generic_token::Mint::unpack(&account_data, &token::id()), + None + ); + assert_eq!( + generic_token::Mint::unpack(&account_data, &token_2022::id()), + None + ); + } +} diff --git a/generic-token/Cargo.toml b/generic-token/Cargo.toml new file mode 100644 index 00000000..87b3c92f --- /dev/null +++ b/generic-token/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "spl-generic-token" +version = "0.0.0" +description = "Solana Program Library Generic Token" +authors = ["Anza Maintainers "] +repository = "https://github.com/solana-program/libraries" +license = "Apache-2.0" +edition = "2021" + +[dependencies] +bytemuck = "1.22.0" +solana-pubkey = { version = "2.2.1", default-features = false, features = [ + "bytemuck", +] } + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/generic-token/README.md b/generic-token/README.md new file mode 100644 index 00000000..bcd54b62 --- /dev/null +++ b/generic-token/README.md @@ -0,0 +1,37 @@ +# SPL Generic Token + +Library that provides bare-bones, dependency-minimized access to SPL Token balance information. + +## Example usage + +This library provides two core structs: + +```rust +spl_generic_token::generic_token::Account { + mint: Pubkey, + owner: Pubkey, + amount: u64, +} + +spl_generic_token::generic_token::Mint { + supply: u64, + decimals: u8, +} +``` + +Both provide a static function `fn unpack(account_data: &[u8], program_id: &Pubkey) -> Option` +which extracts the above fields from a raw buffer in a manner that is generic across `spl_token` +and `spl_token_2022`, without depending on either library. + +This is only intended as a simple way to determine balances and direct account ownership. Users +who require additional information such as delegation, mint authority, and so on, should use +the full account parsers in the respective token libraries, as those use-cases exceed the scope of +this tool. + +We also provide the trait `GenericTokenAccount` which exposes direct access to the fields named above. + +## Note to maintainers + +This library is used in parts of Agave that _must not_ depend on `spl_token`, `spl_token_2022`, or +other outside Solana libraries. Care should be taken not to introduce dependencies that may +complicate this situation. diff --git a/generic-token/src/associated_token_account.rs b/generic-token/src/associated_token_account.rs new file mode 100644 index 00000000..609df050 --- /dev/null +++ b/generic-token/src/associated_token_account.rs @@ -0,0 +1,3 @@ +//! Partial SPL Associated Token Account declarations to avoid a dependency on the spl-associated-token-account crate. + +solana_pubkey::declare_id!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); diff --git a/generic-token/src/generic_token.rs b/generic-token/src/generic_token.rs new file mode 100644 index 00000000..fe781c62 --- /dev/null +++ b/generic-token/src/generic_token.rs @@ -0,0 +1,82 @@ +//! Minimum viable SPL Token parsers to avoid a dependency on the spl-token and spl-token-2022 crates. +//! Users may use the generic traits directly, but this requires them to select the correct implementation +//! based on the account's program id. `generic_token::Account` and `generic_token::Mint` abstract over +//! this and require no knowledge of the different token programs on the part of the caller at all. +//! +//! We provide the minimum viable interface to determine balances and ownership. For more advanced use-cases, +//! it is recommended to use to full token program crates instead. + +use { + crate::{ + token::{self, GenericTokenAccount, GenericTokenMint}, + token_2022, + }, + solana_pubkey::Pubkey, +}; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Account { + pub mint: Pubkey, + pub owner: Pubkey, + pub amount: u64, +} + +impl Account { + pub fn unpack(account_data: &[u8], program_id: &Pubkey) -> Option { + let (mint, owner, amount) = if *program_id == token::id() { + token::Account::valid_account_data(account_data).then_some(())?; + + let mint = token::Account::unpack_account_mint_unchecked(account_data); + let owner = token::Account::unpack_account_owner_unchecked(account_data); + let amount = token::Account::unpack_account_amount_unchecked(account_data); + + (*mint, *owner, amount) + } else if *program_id == token_2022::id() { + token_2022::Account::valid_account_data(account_data).then_some(())?; + + let mint = token_2022::Account::unpack_account_mint_unchecked(account_data); + let owner = token_2022::Account::unpack_account_owner_unchecked(account_data); + let amount = token_2022::Account::unpack_account_amount_unchecked(account_data); + + (*mint, *owner, amount) + } else { + return None; + }; + + Some(Self { + mint, + owner, + amount, + }) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Mint { + pub supply: u64, + pub decimals: u8, +} + +impl Mint { + pub fn unpack(account_data: &[u8], program_id: &Pubkey) -> Option { + let (supply, decimals) = if *program_id == token::id() { + token::Mint::valid_account_data(account_data).then_some(())?; + + let supply = token::Mint::unpack_mint_supply_unchecked(account_data); + let decimals = token::Mint::unpack_mint_decimals_unchecked(account_data); + + (supply, decimals) + } else if *program_id == token_2022::id() { + token_2022::Mint::valid_account_data(account_data).then_some(())?; + + let supply = token_2022::Mint::unpack_mint_supply_unchecked(account_data); + let decimals = token_2022::Mint::unpack_mint_decimals_unchecked(account_data); + + (supply, decimals) + } else { + return None; + }; + + Some(Self { supply, decimals }) + } +} diff --git a/generic-token/src/lib.rs b/generic-token/src/lib.rs new file mode 100644 index 00000000..cc0ccf2f --- /dev/null +++ b/generic-token/src/lib.rs @@ -0,0 +1,16 @@ +use solana_pubkey::Pubkey; + +pub mod associated_token_account; +pub mod generic_token; +pub mod token; +pub mod token_2022; + +/// Returns all known SPL Token program ids +pub fn spl_token_ids() -> Vec { + vec![token::id(), token_2022::id()] +} + +/// Check if the provided program id as a known SPL Token program id +pub fn is_known_spl_token_id(program_id: &Pubkey) -> bool { + *program_id == token::id() || *program_id == token_2022::id() +} diff --git a/generic-token/src/token.rs b/generic-token/src/token.rs new file mode 100644 index 00000000..5089d55f --- /dev/null +++ b/generic-token/src/token.rs @@ -0,0 +1,170 @@ +//! Partial SPL Token declarations to avoid a dependency on the spl-token crate. + +use { + solana_pubkey::{Pubkey, PUBKEY_BYTES}, + std::mem, +}; + +solana_pubkey::declare_id!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + +/* + spl_token::state::Account { + mint: Pubkey, + owner: Pubkey, + amount: u64, + delegate: COption, + state: AccountState, + is_native: COption, + delegated_amount: u64, + close_authority: COption, + } +*/ +const SPL_TOKEN_ACCOUNT_MINT_OFFSET: usize = 0; +const SPL_TOKEN_ACCOUNT_OWNER_OFFSET: usize = 32; +const SPL_TOKEN_ACCOUNT_AMOUNT_OFFSET: usize = 64; +const SPL_TOKEN_ACCOUNT_STATE_OFFSET: usize = 108; +pub(crate) const SPL_TOKEN_ACCOUNT_LENGTH: usize = 165; + +/* + spl_token::state::Mint { + mint_authority: COption, + supply: u64, + decimals: u8, + is_initialized: bool, + freeze_authority: COption, + } +*/ +const SPL_TOKEN_MINT_SUPPLY_OFFSET: usize = 36; +const SPL_TOKEN_MINT_DECIMALS_OFFSET: usize = 44; +const SPL_TOKEN_MINT_IS_INITIALIZED_OFFSET: usize = 45; +pub(crate) const SPL_TOKEN_MINT_LENGTH: usize = 82; + +pub(crate) fn is_initialized_account(account_data: &[u8]) -> bool { + is_initialized_token_data(account_data, SPL_TOKEN_ACCOUNT_STATE_OFFSET) +} + +pub(crate) fn is_initialized_mint(account_data: &[u8]) -> bool { + is_initialized_token_data(account_data, SPL_TOKEN_MINT_IS_INITIALIZED_OFFSET) +} + +fn is_initialized_token_data(account_data: &[u8], offset: usize) -> bool { + *account_data.get(offset).unwrap_or(&0) != 0 +} + +macro_rules! define_checked_getter { + ($checked_fn:ident, $unchecked_fn:ident, $typ:ty) => { + fn $checked_fn(account_data: &[u8]) -> Option<$typ> { + if Self::valid_account_data(account_data) { + Some(Self::$unchecked_fn(account_data)) + } else { + None + } + } + }; +} + +// necessary to forgo bytemuck to treat endianness correctly on BE systems +fn unpack_u64_unchecked(account_data: &[u8], offset: usize) -> u64 { + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&account_data[offset..offset.wrapping_add(mem::size_of::())]); + u64::from_le_bytes(bytes) +} + +// Trait for retrieving mint address, owner, and amount from any token account-like buffer. +// A token program that copies the spl_token layout need only impl `valid_account_data()`. +pub trait GenericTokenAccount { + fn valid_account_data(account_data: &[u8]) -> bool; + + define_checked_getter!(unpack_account_mint, unpack_account_mint_unchecked, &Pubkey); + define_checked_getter!( + unpack_account_owner, + unpack_account_owner_unchecked, + &Pubkey + ); + define_checked_getter!(unpack_account_amount, unpack_account_amount_unchecked, u64); + + // Call after account length has already been verified + fn unpack_account_mint_unchecked(account_data: &[u8]) -> &Pubkey { + Self::unpack_pubkey_unchecked(account_data, SPL_TOKEN_ACCOUNT_MINT_OFFSET) + } + + // Call after account length has already been verified + fn unpack_account_owner_unchecked(account_data: &[u8]) -> &Pubkey { + Self::unpack_pubkey_unchecked(account_data, SPL_TOKEN_ACCOUNT_OWNER_OFFSET) + } + + // Call after account length has already been verified + fn unpack_account_amount_unchecked(account_data: &[u8]) -> u64 { + unpack_u64_unchecked(account_data, SPL_TOKEN_ACCOUNT_AMOUNT_OFFSET) + } + + // Call after account length has already been verified + fn unpack_pubkey_unchecked(account_data: &[u8], offset: usize) -> &Pubkey { + bytemuck::from_bytes(&account_data[offset..offset.wrapping_add(PUBKEY_BYTES)]) + } +} + +pub struct Account; +impl Account { + pub const fn get_packed_len() -> usize { + SPL_TOKEN_ACCOUNT_LENGTH + } +} + +impl GenericTokenAccount for Account { + fn valid_account_data(account_data: &[u8]) -> bool { + account_data.len() == SPL_TOKEN_ACCOUNT_LENGTH && is_initialized_account(account_data) + } +} + +// Trait for retrieving supply and decimals from any token mint-like buffer. +// A token program that copies the spl_token layout need only impl `valid_account_data()`. +// We do not use bytemuck for this because Mint is an unaligned struct. +pub trait GenericTokenMint { + fn valid_account_data(account_data: &[u8]) -> bool; + + define_checked_getter!(unpack_mint_supply, unpack_mint_supply_unchecked, u64); + define_checked_getter!(unpack_mint_decimals, unpack_mint_decimals_unchecked, u8); + + // Call after account length has already been verified + fn unpack_mint_supply_unchecked(account_data: &[u8]) -> u64 { + unpack_u64_unchecked(account_data, SPL_TOKEN_MINT_SUPPLY_OFFSET) + } + + // Call after account length has already been verified + fn unpack_mint_decimals_unchecked(account_data: &[u8]) -> u8 { + account_data[SPL_TOKEN_MINT_DECIMALS_OFFSET] + } +} + +pub struct Mint; +impl Mint { + pub const fn get_packed_len() -> usize { + SPL_TOKEN_MINT_LENGTH + } +} + +impl GenericTokenMint for Mint { + fn valid_account_data(account_data: &[u8]) -> bool { + account_data.len() == SPL_TOKEN_MINT_LENGTH && is_initialized_mint(account_data) + } +} + +pub mod native_mint { + solana_pubkey::declare_id!("So11111111111111111111111111111111111111112"); + + /* + spl_token::state::Mint { + mint_authority: COption::None, + supply: 0, + decimals: 9, + is_initialized: true, + freeze_authority: COption::None, + } + */ + pub const ACCOUNT_DATA: [u8; 82] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]; +} diff --git a/generic-token/src/token_2022.rs b/generic-token/src/token_2022.rs new file mode 100644 index 00000000..d4d1b8ff --- /dev/null +++ b/generic-token/src/token_2022.rs @@ -0,0 +1,43 @@ +//! Partial SPL Token declarations to avoid a dependency on the spl-token-2022 crate. + +use crate::token::{ + self, is_initialized_account, is_initialized_mint, GenericTokenAccount, GenericTokenMint, + SPL_TOKEN_ACCOUNT_LENGTH, +}; + +solana_pubkey::declare_id!("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"); + +// `spl_token_program_2022::extension::AccountType::Account` ordinal value +const ACCOUNTTYPE_ACCOUNT: u8 = 2; + +// Token2022 enforces that TLV data cannot make a Mint or Account that is precisely +// the length of a Multisig, to allow them to be distinguished. +const SPL_TOKEN_MULTISIG_LENGTH: usize = 355; + +pub struct Account; +impl GenericTokenAccount for Account { + fn valid_account_data(account_data: &[u8]) -> bool { + token::Account::valid_account_data(account_data) + || (account_data.len() > SPL_TOKEN_ACCOUNT_LENGTH + && account_data.len() != SPL_TOKEN_MULTISIG_LENGTH + && ACCOUNTTYPE_ACCOUNT == account_data[SPL_TOKEN_ACCOUNT_LENGTH] + && is_initialized_account(account_data)) + } +} + +// `spl_token_program_2022::extension::AccountType::Mint` ordinal value +const ACCOUNTTYPE_MINT: u8 = 1; + +pub struct Mint; +impl GenericTokenMint for Mint { + // NOTE `account_data.len() > SPL_TOKEN_ACCOUNT_LENGTH` is intentional. + // We use Account length, not Mint length, because an extended Mint is + // padded out to Account length so an Account cannot masquerade as a Mint. + fn valid_account_data(account_data: &[u8]) -> bool { + token::Mint::valid_account_data(account_data) + || (account_data.len() > SPL_TOKEN_ACCOUNT_LENGTH + && account_data.len() != SPL_TOKEN_MULTISIG_LENGTH + && ACCOUNTTYPE_MINT == account_data[SPL_TOKEN_ACCOUNT_LENGTH] + && is_initialized_mint(account_data)) + } +} diff --git a/package.json b/package.json index 00e03244..0cacfb83 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,13 @@ "discriminator:test": "zx ./scripts/rust/test.mjs discriminator", "discriminator:format": "zx ./scripts/rust/format.mjs discriminator", "discriminator:lint": "zx ./scripts/rust/lint.mjs discriminator", + "generic-token:build": "zx ./scripts/rust/build-sbf.mjs generic-token", + "generic-token:test": "zx ./scripts/rust/test.mjs generic-token", + "generic-token:format": "zx ./scripts/rust/format.mjs generic-token", + "generic-token:lint": "zx ./scripts/rust/lint.mjs generic-token", + "generic-token-tests:test": "zx ./scripts/rust/test.mjs generic-token-tests", + "generic-token-tests:format": "zx ./scripts/rust/format.mjs generic-token-tests", + "generic-token-tests:lint": "zx ./scripts/rust/lint.mjs generic-token-tests", "program-error:build": "zx ./scripts/rust/build-sbf.mjs program-error", "program-error:test": "zx ./scripts/rust/test.mjs program-error", "program-error:format": "zx ./scripts/rust/format.mjs program-error",