diff --git a/Cargo.lock b/Cargo.lock index aaf232da..b45c9778 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3064,13 +3064,14 @@ version = "0.0.0" dependencies = [ "pinocchio", "pinocchio-pubkey", + "rand 0.8.5", "strum 0.27.1", "strum_macros 0.27.1", ] [[package]] name = "pinocchio-token-program" -version = "0.0.0" +version = "0.1.0" dependencies = [ "agave-feature-set", "assert_matches", diff --git a/p-interface/Cargo.toml b/p-interface/Cargo.toml index 20a49ed3..f51dc551 100644 --- a/p-interface/Cargo.toml +++ b/p-interface/Cargo.toml @@ -16,5 +16,6 @@ pinocchio = { workspace = true } pinocchio-pubkey = "0.3" [dev-dependencies] +rand = "0.8" strum = "0.27" strum_macros = "0.27" diff --git a/p-interface/src/lib.rs b/p-interface/src/lib.rs index ab8c3ecb..68da0366 100644 --- a/p-interface/src/lib.rs +++ b/p-interface/src/lib.rs @@ -6,7 +6,7 @@ pub mod native_mint; pub mod state; pub mod program { - pinocchio_pubkey::declare_id!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + pinocchio_pubkey::declare_id!("cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m"); } /// A "dummy" function with a hint to the compiler that it is unlikely to be diff --git a/p-interface/src/state/account.rs b/p-interface/src/state/account.rs index a9ade92a..8a47b9c0 100644 --- a/p-interface/src/state/account.rs +++ b/p-interface/src/state/account.rs @@ -154,6 +154,7 @@ impl Account { unsafe impl Transmutable for Account { const LEN: usize = core::mem::size_of::(); + const ACCOUNT_TYPE: u8 = super::ACCOUNT_TYPE_TOKEN_ACCOUNT; } impl Initializable for Account { diff --git a/p-interface/src/state/mint.rs b/p-interface/src/state/mint.rs index 655173e6..2bb57084 100644 --- a/p-interface/src/state/mint.rs +++ b/p-interface/src/state/mint.rs @@ -87,6 +87,7 @@ impl Mint { unsafe impl Transmutable for Mint { /// The length of the `Mint` account data. const LEN: usize = core::mem::size_of::(); + const ACCOUNT_TYPE: u8 = super::ACCOUNT_TYPE_MINT; } impl Initializable for Mint { diff --git a/p-interface/src/state/mod.rs b/p-interface/src/state/mod.rs index 79505eed..28f6a23a 100644 --- a/p-interface/src/state/mod.rs +++ b/p-interface/src/state/mod.rs @@ -8,6 +8,14 @@ pub mod multisig; /// Type alias for fields represented as `COption`. pub type COption = ([u8; 4], T); +/// AccountType discriminator for Mint accounts. +/// Validation checks byte 165 (ACCOUNT_TYPE_OFFSET) for all extended accounts. +pub const ACCOUNT_TYPE_MINT: u8 = 1; + +/// AccountType discriminator for Token accounts. +/// Validation checks byte 165 (ACCOUNT_TYPE_OFFSET) for all extended accounts. +pub const ACCOUNT_TYPE_TOKEN_ACCOUNT: u8 = 2; + /// Marker trait for types that can be cast from a raw pointer. /// /// # Safety @@ -20,6 +28,10 @@ pub unsafe trait Transmutable { /// /// This must be equal to the size of each individual field in the type. const LEN: usize; + + /// The expected AccountType discriminator value at byte offset `LEN` for extended accounts. + /// Used to validate account type when `bytes.len() > LEN`. + const ACCOUNT_TYPE: u8; } /// Trait to represent a type that can be initialized. @@ -35,7 +47,7 @@ pub trait Initializable { /// The caller must ensure that `bytes` contains a valid representation of `T`. #[inline(always)] pub unsafe fn load(bytes: &[u8]) -> Result<&T, ProgramError> { - load_unchecked(bytes).and_then(|t: &T| { + load_unchecked(&bytes).and_then(|t: &T| { // checks if the data is initialized if t.is_initialized()? { Ok(t) @@ -45,19 +57,31 @@ pub unsafe fn load(bytes: &[u8]) -> Result<&T, }) } +/// Byte offset of AccountType discriminator in extended accounts. +pub const ACCOUNT_TYPE_OFFSET: usize = 165; + /// Return a `T` reference from the given bytes. /// /// This function does not check if the data is initialized. /// +/// Accepts: +/// - Exact length match: `bytes.len() == T::LEN` (standard account) +/// - Extended account: `bytes.len() > ACCOUNT_TYPE_OFFSET` with matching AccountType at byte 165 +/// +/// Rejects everything else (too short, ambiguous size, wrong AccountType). +/// /// # Safety /// /// The caller must ensure that `bytes` contains a valid representation of `T`. #[inline(always)] pub unsafe fn load_unchecked(bytes: &[u8]) -> Result<&T, ProgramError> { - if bytes.len() != T::LEN { - return Err(ProgramError::InvalidAccountData); + if bytes.len() == T::LEN { + return Ok(&*(bytes[..T::LEN].as_ptr() as *const T)); + } + if bytes.len() > ACCOUNT_TYPE_OFFSET && bytes[ACCOUNT_TYPE_OFFSET] == T::ACCOUNT_TYPE { + return Ok(&*(bytes[..T::LEN].as_ptr() as *const T)); } - Ok(&*(bytes.as_ptr() as *const T)) + Err(ProgramError::InvalidAccountData) } /// Return a mutable reference for an initialized `T` from the given bytes. @@ -83,6 +107,12 @@ pub unsafe fn load_mut( /// /// This function does not check if the data is initialized. /// +/// Accepts: +/// - Exact length match: `bytes.len() == T::LEN` (standard account) +/// - Extended account: `bytes.len() > ACCOUNT_TYPE_OFFSET` with matching AccountType at byte 165 +/// +/// Rejects everything else (too short, ambiguous size, wrong AccountType). +/// /// # Safety /// /// The caller must ensure that `bytes` contains a valid representation of `T`. @@ -90,8 +120,11 @@ pub unsafe fn load_mut( pub unsafe fn load_mut_unchecked( bytes: &mut [u8], ) -> Result<&mut T, ProgramError> { - if bytes.len() != T::LEN { - return Err(ProgramError::InvalidAccountData); + if bytes.len() == T::LEN { + return Ok(&mut *(bytes[..T::LEN].as_mut_ptr() as *mut T)); + } + if bytes.len() > ACCOUNT_TYPE_OFFSET && bytes[ACCOUNT_TYPE_OFFSET] == T::ACCOUNT_TYPE { + return Ok(&mut *(bytes[..T::LEN].as_mut_ptr() as *mut T)); } - Ok(&mut *(bytes.as_mut_ptr() as *mut T)) + Err(ProgramError::InvalidAccountData) } diff --git a/p-interface/src/state/multisig.rs b/p-interface/src/state/multisig.rs index 7e453fd2..17efbff0 100644 --- a/p-interface/src/state/multisig.rs +++ b/p-interface/src/state/multisig.rs @@ -41,6 +41,8 @@ impl Multisig { unsafe impl Transmutable for Multisig { /// The length of the `Multisig` account data. const LEN: usize = core::mem::size_of::(); + /// Multisig not supported for extended accounts (255 = invalid/unused type). + const ACCOUNT_TYPE: u8 = 255; } impl Initializable for Multisig { diff --git a/p-interface/tests/load_validation.rs b/p-interface/tests/load_validation.rs new file mode 100644 index 00000000..ce2a90c3 --- /dev/null +++ b/p-interface/tests/load_validation.rs @@ -0,0 +1,462 @@ +//! Tests for load_unchecked and load_mut_unchecked AccountType validation. +//! +//! These tests verify that the account loading functions properly validate +//! account types to prevent type confusion vulnerabilities (e.g., loading +//! a Token account as a Mint). + +use pinocchio_token_interface::state::{ + account::Account, load_mut_unchecked, load_unchecked, mint::Mint, Transmutable, + ACCOUNT_TYPE_MINT, ACCOUNT_TYPE_OFFSET, ACCOUNT_TYPE_TOKEN_ACCOUNT, +}; + +// ============================================================================ +// load_unchecked tests +// ============================================================================ + +#[test] +fn test_load_unchecked_exact_length_mint() { + // Standard Mint (82 bytes) should load as Mint + let data = vec![0u8; Mint::LEN]; + let result = unsafe { load_unchecked::(&data) }; + assert!(result.is_ok()); +} + +#[test] +fn test_load_unchecked_exact_length_account() { + // Standard Account (165 bytes) should load as Account + let data = vec![0u8; Account::LEN]; + let result = unsafe { load_unchecked::(&data) }; + assert!(result.is_ok()); +} + +#[test] +fn test_load_unchecked_rejects_token_as_mint() { + // CRITICAL: 165-byte Token account data should NOT load as Mint + // This is the main vulnerability being fixed + let data = vec![0u8; Account::LEN]; // 165 bytes + let result = unsafe { load_unchecked::(&data) }; + assert!( + result.is_err(), + "Should reject Token account loaded as Mint" + ); +} + +#[test] +fn test_load_unchecked_rejects_ambiguous_size_as_mint() { + // Data between Mint::LEN (82) and ACCOUNT_TYPE_OFFSET (165) should be rejected + // because we can't verify the type without a discriminator + let data = vec![0u8; 100]; // 82 < 100 < 165 + let result = unsafe { load_unchecked::(&data) }; + assert!( + result.is_err(), + "Should reject ambiguous size without discriminator" + ); +} + +#[test] +fn test_load_unchecked_rejects_extended_token_as_mint() { + // Extended Token account (>165 bytes, type=2) should NOT load as Mint + let mut data = vec![0u8; 200]; + data[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_TOKEN_ACCOUNT; // type = 2 + let result = unsafe { load_unchecked::(&data) }; + assert!( + result.is_err(), + "Should reject extended Token loaded as Mint" + ); +} + +#[test] +fn test_load_unchecked_rejects_extended_mint_as_account() { + // Extended Mint (>165 bytes, type=1) should NOT load as Account + let mut data = vec![0u8; 200]; + data[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_MINT; // type = 1 + let result = unsafe { load_unchecked::(&data) }; + assert!( + result.is_err(), + "Should reject extended Mint loaded as Account" + ); +} + +#[test] +fn test_load_unchecked_accepts_extended_mint() { + // Extended Mint (>165 bytes, type=1) should load as Mint + let mut data = vec![0u8; 200]; + data[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_MINT; // type = 1 + let result = unsafe { load_unchecked::(&data) }; + assert!( + result.is_ok(), + "Should accept extended Mint with correct type" + ); +} + +#[test] +fn test_load_unchecked_accepts_extended_account() { + // Extended Account (>165 bytes, type=2) should load as Account + let mut data = vec![0u8; 200]; + data[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_TOKEN_ACCOUNT; // type = 2 + let result = unsafe { load_unchecked::(&data) }; + assert!( + result.is_ok(), + "Should accept extended Account with correct type" + ); +} + +#[test] +fn test_load_unchecked_rejects_too_short_for_mint() { + let data = vec![0u8; 50]; // Too short for Mint (82 bytes) + let result = unsafe { load_unchecked::(&data) }; + assert!(result.is_err(), "Should reject data shorter than Mint::LEN"); +} + +#[test] +fn test_load_unchecked_rejects_too_short_for_account() { + let data = vec![0u8; 100]; // Too short for Account (165 bytes) + let result = unsafe { load_unchecked::(&data) }; + assert!( + result.is_err(), + "Should reject data shorter than Account::LEN" + ); +} + +#[test] +fn test_load_unchecked_rejects_size_83_as_mint() { + // Boundary test: size 83 (just above Mint::LEN of 82) + let data = vec![0u8; 83]; + let result = unsafe { load_unchecked::(&data) }; + assert!( + result.is_err(), + "Should reject size just above Mint::LEN without discriminator" + ); +} + +#[test] +fn test_load_unchecked_rejects_size_164_as_mint() { + // Boundary test: size 164 (one below ACCOUNT_TYPE_OFFSET of 165) + let data = vec![0u8; 164]; + let result = unsafe { load_unchecked::(&data) }; + assert!( + result.is_err(), + "Should reject size just below ACCOUNT_TYPE_OFFSET" + ); +} + +#[test] +fn test_load_unchecked_rejects_mint_as_account() { + // Boundary test: 82-byte Mint data should NOT load as Account (165 bytes) + let data = vec![0u8; Mint::LEN]; // 82 bytes + let result = unsafe { load_unchecked::(&data) }; + assert!( + result.is_err(), + "Should reject Mint-sized data loaded as Account" + ); +} + +#[test] +fn test_load_unchecked_rejects_empty_data() { + let data = vec![0u8; 0]; + let result = unsafe { load_unchecked::(&data) }; + assert!(result.is_err(), "Should reject empty data"); +} + +#[test] +fn test_load_unchecked_rejects_size_81_as_mint() { + // Boundary test: size 81 (just below Mint::LEN of 82) + let data = vec![0u8; 81]; + let result = unsafe { load_unchecked::(&data) }; + assert!(result.is_err(), "Should reject size just below Mint::LEN"); +} + +#[test] +fn test_load_unchecked_rejects_size_164_as_account() { + // Boundary test: size 164 (just below Account::LEN of 165) + let data = vec![0u8; 164]; + let result = unsafe { load_unchecked::(&data) }; + assert!( + result.is_err(), + "Should reject size just below Account::LEN" + ); +} + +#[test] +fn test_load_unchecked_accepts_size_166_as_mint() { + // Boundary test: minimal extended account (166 bytes, just above ACCOUNT_TYPE_OFFSET) + let mut data = vec![0u8; 166]; + data[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_MINT; + let result = unsafe { load_unchecked::(&data) }; + assert!( + result.is_ok(), + "Should accept minimal extended Mint (166 bytes)" + ); +} + +#[test] +fn test_load_unchecked_accepts_size_166_as_account() { + // Boundary test: minimal extended account (166 bytes, just above ACCOUNT_TYPE_OFFSET) + let mut data = vec![0u8; 166]; + data[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_TOKEN_ACCOUNT; + let result = unsafe { load_unchecked::(&data) }; + assert!( + result.is_ok(), + "Should accept minimal extended Account (166 bytes)" + ); +} + +#[test] +fn test_load_unchecked_rejects_size_166_with_type_0() { + // Size 166 with type=0 should be rejected for both Mint and Account + let mut data = vec![0u8; 166]; + data[ACCOUNT_TYPE_OFFSET] = 0; + let result_mint = unsafe { load_unchecked::(&data) }; + let result_account = unsafe { load_unchecked::(&data) }; + assert!(result_mint.is_err(), "Should reject type=0 as Mint"); + assert!(result_account.is_err(), "Should reject type=0 as Account"); +} + +#[test] +fn test_load_unchecked_rejects_size_166_with_invalid_type() { + // Size 166 with invalid type values should be rejected + let mut data = vec![0u8; 166]; + data[ACCOUNT_TYPE_OFFSET] = 255; // Invalid type + let result_mint = unsafe { load_unchecked::(&data) }; + let result_account = unsafe { load_unchecked::(&data) }; + assert!(result_mint.is_err(), "Should reject invalid type as Mint"); + assert!( + result_account.is_err(), + "Should reject invalid type as Account" + ); +} + +// ============================================================================ +// load_mut_unchecked tests +// ============================================================================ + +#[test] +fn test_load_mut_unchecked_exact_length_mint() { + let mut data = vec![0u8; Mint::LEN]; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!(result.is_ok()); +} + +#[test] +fn test_load_mut_unchecked_exact_length_account() { + let mut data = vec![0u8; Account::LEN]; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!(result.is_ok()); +} + +#[test] +fn test_load_mut_unchecked_rejects_token_as_mint() { + // CRITICAL: Same vulnerability check for mutable reference + let mut data = vec![0u8; Account::LEN]; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!( + result.is_err(), + "Should reject Token account loaded as mutable Mint" + ); +} + +#[test] +fn test_load_mut_unchecked_rejects_ambiguous_size_as_mint() { + let mut data = vec![0u8; 100]; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!( + result.is_err(), + "Should reject ambiguous size without discriminator" + ); +} + +#[test] +fn test_load_mut_unchecked_accepts_extended_mint() { + let mut data = vec![0u8; 200]; + data[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_MINT; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!( + result.is_ok(), + "Should accept extended Mint with correct type" + ); +} + +#[test] +fn test_load_mut_unchecked_accepts_extended_account() { + let mut data = vec![0u8; 200]; + data[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_TOKEN_ACCOUNT; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!( + result.is_ok(), + "Should accept extended Account with correct type" + ); +} + +#[test] +fn test_load_mut_unchecked_rejects_extended_token_as_mint() { + let mut data = vec![0u8; 200]; + data[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_TOKEN_ACCOUNT; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!( + result.is_err(), + "Should reject extended Token loaded as mutable Mint" + ); +} + +#[test] +fn test_load_mut_unchecked_rejects_extended_mint_as_account() { + let mut data = vec![0u8; 200]; + data[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_MINT; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!( + result.is_err(), + "Should reject extended Mint loaded as mutable Account" + ); +} + +#[test] +fn test_load_mut_unchecked_rejects_size_83_as_mint() { + // Boundary test: size 83 (just above Mint::LEN of 82) + let mut data = vec![0u8; 83]; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!( + result.is_err(), + "Should reject size just above Mint::LEN without discriminator" + ); +} + +#[test] +fn test_load_mut_unchecked_rejects_size_164_as_mint() { + // Boundary test: size 164 (one below ACCOUNT_TYPE_OFFSET of 165) + let mut data = vec![0u8; 164]; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!( + result.is_err(), + "Should reject size just below ACCOUNT_TYPE_OFFSET" + ); +} + +#[test] +fn test_load_mut_unchecked_rejects_mint_as_account() { + // Boundary test: 82-byte Mint data should NOT load as Account (165 bytes) + let mut data = vec![0u8; Mint::LEN]; // 82 bytes + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!( + result.is_err(), + "Should reject Mint-sized data loaded as mutable Account" + ); +} + +#[test] +fn test_load_mut_unchecked_rejects_empty_data() { + let mut data = vec![0u8; 0]; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!(result.is_err(), "Should reject empty data"); +} + +#[test] +fn test_load_mut_unchecked_rejects_size_81_as_mint() { + let mut data = vec![0u8; 81]; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!(result.is_err(), "Should reject size just below Mint::LEN"); +} + +#[test] +fn test_load_mut_unchecked_rejects_size_164_as_account() { + let mut data = vec![0u8; 164]; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!( + result.is_err(), + "Should reject size just below Account::LEN" + ); +} + +#[test] +fn test_load_mut_unchecked_accepts_size_166_as_mint() { + let mut data = vec![0u8; 166]; + data[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_MINT; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!( + result.is_ok(), + "Should accept minimal extended Mint (166 bytes)" + ); +} + +#[test] +fn test_load_mut_unchecked_accepts_size_166_as_account() { + let mut data = vec![0u8; 166]; + data[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_TOKEN_ACCOUNT; + let result = unsafe { load_mut_unchecked::(&mut data) }; + assert!( + result.is_ok(), + "Should accept minimal extended Account (166 bytes)" + ); +} + +#[test] +fn test_load_mut_unchecked_rejects_size_166_with_type_0() { + let mut data = vec![0u8; 166]; + data[ACCOUNT_TYPE_OFFSET] = 0; + let result_mint = unsafe { load_mut_unchecked::(&mut data) }; + let mut data2 = vec![0u8; 166]; + data2[ACCOUNT_TYPE_OFFSET] = 0; + let result_account = unsafe { load_mut_unchecked::(&mut data2) }; + assert!(result_mint.is_err(), "Should reject type=0 as Mint"); + assert!(result_account.is_err(), "Should reject type=0 as Account"); +} + +#[test] +fn test_load_mut_unchecked_rejects_size_166_with_invalid_type() { + let mut data = vec![0u8; 166]; + data[ACCOUNT_TYPE_OFFSET] = 255; + let result_mint = unsafe { load_mut_unchecked::(&mut data) }; + let mut data2 = vec![0u8; 166]; + data2[ACCOUNT_TYPE_OFFSET] = 255; + let result_account = unsafe { load_mut_unchecked::(&mut data2) }; + assert!(result_mint.is_err(), "Should reject invalid type as Mint"); + assert!( + result_account.is_err(), + "Should reject invalid type as Account" + ); +} + +// ============================================================================ +// Randomized fuzz tests +// ============================================================================ + +#[test] +fn test_load_unchecked_fuzz_rejects_invalid_random_data() { + use rand::Rng; + let mut rng = rand::thread_rng(); + + for i in 0..1_000_000 { + // Random size between 0 and 500 + let size = rng.gen_range(0..500); + let mut data: Vec = (0..size).map(|_| rng.gen()).collect(); + + // If size > ACCOUNT_TYPE_OFFSET, ensure byte[165] is never 1 or 2 + if size > ACCOUNT_TYPE_OFFSET { + while data[ACCOUNT_TYPE_OFFSET] == ACCOUNT_TYPE_MINT + || data[ACCOUNT_TYPE_OFFSET] == ACCOUNT_TYPE_TOKEN_ACCOUNT + { + data[ACCOUNT_TYPE_OFFSET] = rng.gen(); + } + } + + // Skip exact length matches (these are valid cases) + if size == Mint::LEN || size == Account::LEN { + continue; + } + + let result_mint = unsafe { load_unchecked::(&data) }; + let result_account = unsafe { load_unchecked::(&data) }; + + assert!( + result_mint.is_err(), + "Iteration {}: Should reject random data (size={}) as Mint", + i, + size + ); + assert!( + result_account.is_err(), + "Iteration {}: Should reject random data (size={}) as Account", + i, + size + ); + } +} diff --git a/p-token/Cargo.toml b/p-token/Cargo.toml index bab4a551..120f0a8f 100644 --- a/p-token/Cargo.toml +++ b/p-token/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinocchio-token-program" -version = "0.0.0" +version = "0.1.0" description = "A pinocchio-based Token (aka 'p-token') program" authors = { workspace = true} repository = { workspace = true} @@ -9,13 +9,13 @@ edition = { workspace = true} readme = "./README.md" [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "lib"] [features] logging = [] [dependencies] -pinocchio = { workspace = true } +pinocchio = "0.9" pinocchio-log = { version = "0.5", default-features = false } pinocchio-token-interface = { version = "^0", path = "../p-interface" } diff --git a/p-token/README.md b/p-token/README.md index f2c4f515..05c19fa7 100644 --- a/p-token/README.md +++ b/p-token/README.md @@ -1,17 +1,136 @@ -# `p-token` +# p-token -A `pinocchio`-based Token program. +A `pinocchio`-based Token program with library support for external program integration. ## Overview -`p-token` is a reimplementation of the SPL Token program, one of the most popular programs on Solana, using [`pinocchio`](https://github.com/anza-xyz/pinocchio). The purpose is to have an implementation that optimizes the compute units, while being fully compatible with the original implementation — i.e., support the exact same instruction and account layouts as SPL Token, byte for byte. +`p-token` is a reimplementation of the SPL Token program using [`pinocchio`](https://github.com/anza-xyz/pinocchio). Optimizes compute units while maintaining full compatibility with SPL Token instruction and account layouts. ## Features -- `no_std` crate - Same instruction and account layout as SPL Token - Minimal CU usage +- Library mode for external program integration +- Token-2022 extension account compatibility +## Changes (jorrit/refactor branch) + +### 1. Transfer Authority Pre-validation Parameter + +**path:** `src/processor/shared/transfer.rs:14-17, 127-152` + +Added `signer_is_validated: bool` parameter to transfer functions. + +**behavior:** +- `false` (default): Full authority validation via `validate_owner()` - checks owner/delegate and signer status +- `true`: Authority validation skipped - caller has already validated authority externally + +**affected functions:** +- `shared::transfer::process_transfer(accounts, amount, expected_decimals, signer_is_validated)` +- `transfer::process_transfer(accounts, instruction_data, signer_is_validated)` +- `transfer_checked::process_transfer_checked(accounts, instruction_data, signer_is_validated)` + +**usage:** External programs (e.g., compressed-token) validate permanent delegate authority before calling p-token, then pass `signer_is_validated=true` to avoid redundant validation. + +**security requirement:** Caller MUST verify `authority.is_signer()` before setting `signer_is_validated=true`. Failure to do so enables unauthorized transfers. + +### 2. Account Size Validation with AccountType Check + +**path:** `../p-interface/src/state/mod.rs:61-83, 104-128` + +`load_unchecked` and `load_mut_unchecked` validate account data with strict type checking. + +**before:** +```rust +if bytes.len() != T::LEN { + return Err(ProgramError::InvalidAccountData); +} +Ok(&*(bytes.as_ptr() as *const T)) +``` + +**after:** +```rust +if bytes.len() == T::LEN { + return Ok(&*(bytes[..T::LEN].as_ptr() as *const T)); +} +if bytes.len() > ACCOUNT_TYPE_OFFSET && bytes[ACCOUNT_TYPE_OFFSET] == T::ACCOUNT_TYPE { + return Ok(&*(bytes[..T::LEN].as_ptr() as *const T)); +} +Err(ProgramError::InvalidAccountData) +``` + +**accepts:** +- Exact length match: `bytes.len() == T::LEN` (standard accounts) +- Extended accounts: `bytes.len() > 165` with matching AccountType at byte 165 + +**rejects:** +- Too short: `bytes.len() < T::LEN` +- Ambiguous size: `T::LEN < bytes.len() <= 165` (cannot verify type) +- Wrong AccountType: extended account with mismatched discriminator + +**purpose:** Enables processing of Token-2022 extension accounts while preventing type confusion attacks. + +**security:** +- AccountType discriminator at byte 165 validated for extended accounts: + - `ACCOUNT_TYPE_MINT = 1` for Mint accounts + - `ACCOUNT_TYPE_TOKEN_ACCOUNT = 2` for Token accounts +- Prevents loading a Token account (165 bytes) as Mint (82 bytes) by rejecting ambiguous sizes +- Prevents loading extended accounts with wrong AccountType + +### 3. Library Conversion + +**path:** `Cargo.toml`, `src/lib.rs` + +**changes:** +- `crate-type = ["cdylib", "lib"]` - enables both program deployment and library import +- `pub mod processor` - exports processor functions for external use +- Removed `#![no_std]` from lib.rs root (processor module retains no_std behavior) + +**purpose:** Allows external programs to import and call p-token functions directly instead of via CPI. + +### 4. Public API Addition + +**path:** `src/processor/mod.rs:206` + +`unpack_amount_and_decimals` made public: +```rust +pub const fn unpack_amount_and_decimals(instruction_data: &[u8]) -> Result<(u64, u8), TokenError> +``` + +**purpose:** Enables external programs to parse transfer_checked instruction data. + +### 5. Program ID Change + +**path:** `../p-interface/src/lib.rs:9` + +Program ID updated from `TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA` to `cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m`. + +**purpose:** Distinguishes p-token/ctoken deployments from standard SPL Token program. + +## Integration Pattern + +External programs using p-token as a library: + +1. Validate authority externally (e.g., permanent delegate check with `is_signer()`) +2. Call p-token transfer with appropriate `signer_is_validated` flag: + +```rust +use pinocchio_token_program::processor::shared::transfer::process_transfer; + +// After validating permanent delegate is signer +let signer_is_validated = validate_permanent_delegate(mint_checks, authority)?; + +// Call p-token - authority validation skipped if signer_is_validated=true +process_transfer(accounts, amount, expected_decimals, signer_is_validated)?; +``` + +## Unsupported Features + +**Multisig:** Multisig accounts are not supported. The AccountType validation at byte 165 does not handle Multisig accounts (355 bytes), which have no discriminator at that offset. Token-2022 handles this via size exclusion (`!= 355`), but this implementation assumes no multisig usage. + +**Batch module:** Commented out in mod.rs. Batch instruction processing (discriminator 255) currently disabled. + +**Wrapped SOL (native mint):** Transfers of wrapped SOL accounts are not supported. The native mint lamport transfer logic in `src/processor/shared/transfer.rs` is commented out, and transfers will return `NativeNotSupported` error. ## License diff --git a/p-token/src/lib.rs b/p-token/src/lib.rs index 0bd44399..f2df83ff 100644 --- a/p-token/src/lib.rs +++ b/p-token/src/lib.rs @@ -1,6 +1,6 @@ //! Another ERC20-like Token program for the Solana blockchain. -#![no_std] +pub mod processor; -mod entrypoint; -mod processor; +// Re-export from pinocchio-token-interface +pub use pinocchio_token_interface::error; diff --git a/p-token/src/processor/mod.rs b/p-token/src/processor/mod.rs index 155e329f..c87d7694 100644 --- a/p-token/src/processor/mod.rs +++ b/p-token/src/processor/mod.rs @@ -19,7 +19,7 @@ use { pub mod amount_to_ui_amount; pub mod approve; pub mod approve_checked; -pub mod batch; +// pub mod batch; pub mod burn; pub mod burn_checked; pub mod close_account; @@ -48,7 +48,7 @@ pub mod shared; pub use { amount_to_ui_amount::process_amount_to_ui_amount, approve::process_approve, - approve_checked::process_approve_checked, batch::process_batch, burn::process_burn, + approve_checked::process_approve_checked, burn::process_burn, burn_checked::process_burn_checked, close_account::process_close_account, freeze_account::process_freeze_account, get_account_data_size::process_get_account_data_size, initialize_account::process_initialize_account, @@ -203,7 +203,7 @@ const fn unpack_amount(instruction_data: &[u8]) -> Result { /// Unpacks a `u64` amount and an optional `u8` from the instruction data. #[inline(always)] -const fn unpack_amount_and_decimals(instruction_data: &[u8]) -> Result<(u64, u8), TokenError> { +pub const fn unpack_amount_and_decimals(instruction_data: &[u8]) -> Result<(u64, u8), TokenError> { // expected u64 (8) + u8 (1) if instruction_data.len() >= 9 { let (amount, decimals) = instruction_data.split_at(U64_BYTES); diff --git a/p-token/src/processor/shared/transfer.rs b/p-token/src/processor/shared/transfer.rs index 13351e06..632870d7 100644 --- a/p-token/src/processor/shared/transfer.rs +++ b/p-token/src/processor/shared/transfer.rs @@ -14,6 +14,7 @@ pub fn process_transfer( accounts: &[AccountInfo], amount: u64, expected_decimals: Option, + signer_is_validated: bool, ) -> ProgramResult { // Accounts expected depend on whether we have the mint `decimals` or not; when // we have the mint `decimals`, we expect the mint account to be present. @@ -127,25 +128,27 @@ pub fn process_transfer( // Validates the authority (delegate or owner). - if source_account.delegate() == Some(authority_info.key()) { - // SAFETY: `authority_info` is not currently borrowed. - unsafe { validate_owner(authority_info.key(), authority_info, remaining)? }; + if !signer_is_validated { + if source_account.delegate() == Some(authority_info.key()) { + // SAFETY: `authority_info` is not currently borrowed. + unsafe { validate_owner(authority_info.key(), authority_info, remaining)? }; - let delegated_amount = source_account - .delegated_amount() - .checked_sub(amount) - .ok_or(TokenError::InsufficientFunds)?; + let delegated_amount = source_account + .delegated_amount() + .checked_sub(amount) + .ok_or(TokenError::InsufficientFunds)?; - if !self_transfer { - source_account.set_delegated_amount(delegated_amount); + if !self_transfer { + source_account.set_delegated_amount(delegated_amount); - if delegated_amount == 0 { - source_account.clear_delegate(); + if delegated_amount == 0 { + source_account.clear_delegate(); + } } + } else { + // SAFETY: `authority_info` is not currently borrowed. + unsafe { validate_owner(&source_account.owner, authority_info, remaining)? }; } - } else { - // SAFETY: `authority_info` is not currently borrowed. - unsafe { validate_owner(&source_account.owner, authority_info, remaining)? }; } if self_transfer || amount == 0 { @@ -170,20 +173,24 @@ pub fn process_transfer( destination_account.set_amount(destination_account.amount() + amount); if source_account.is_native() { - // SAFETY: single mutable borrow to `source_account_info` lamports. - let source_lamports = unsafe { source_account_info.borrow_mut_lamports_unchecked() }; - // Note: The amount of a source token account is already validated and the - // `lamports` on the account is always greater than `amount`. - *source_lamports -= amount; - - // SAFETY: single mutable borrow to `destination_account_info` lamports; the - // account is already validated to be different from - // `source_account_info`. - let destination_lamports = - unsafe { destination_account_info.borrow_mut_lamports_unchecked() }; - // Note: The total lamports supply is bound to `u64::MAX`. - *destination_lamports += amount; + return Err(TokenError::NativeNotSupported.into()); } + + // if source_account.is_native() { + // // SAFETY: single mutable borrow to `source_account_info` lamports. + // let source_lamports = unsafe { source_account_info.borrow_mut_lamports_unchecked() }; + // // Note: The amount of a source token account is already validated and the + // // `lamports` on the account is always greater than `amount`. + // *source_lamports -= amount; + // + // // SAFETY: single mutable borrow to `destination_account_info` lamports; the + // // account is already validated to be different from + // // `source_account_info`. + // let destination_lamports = + // unsafe { destination_account_info.borrow_mut_lamports_unchecked() }; + // // Note: The total lamports supply is bound to `u64::MAX`. + // *destination_lamports += amount; + // } } Ok(()) diff --git a/p-token/src/processor/transfer.rs b/p-token/src/processor/transfer.rs index 2d75354e..46d77f66 100644 --- a/p-token/src/processor/transfer.rs +++ b/p-token/src/processor/transfer.rs @@ -4,8 +4,12 @@ use { }; #[inline(always)] -pub fn process_transfer(accounts: &[AccountInfo], instruction_data: &[u8]) -> ProgramResult { +pub fn process_transfer( + accounts: &[AccountInfo], + instruction_data: &[u8], + signer_is_validated: bool, +) -> ProgramResult { let amount = unpack_amount(instruction_data)?; - shared::transfer::process_transfer(accounts, amount, None) + shared::transfer::process_transfer(accounts, amount, None, signer_is_validated) } diff --git a/p-token/src/processor/transfer_checked.rs b/p-token/src/processor/transfer_checked.rs index 9a1a895d..32dd2a1a 100644 --- a/p-token/src/processor/transfer_checked.rs +++ b/p-token/src/processor/transfer_checked.rs @@ -7,8 +7,9 @@ use { pub fn process_transfer_checked( accounts: &[AccountInfo], instruction_data: &[u8], + signer_is_validated: bool, ) -> ProgramResult { let (amount, decimals) = unpack_amount_and_decimals(instruction_data)?; - shared::transfer::process_transfer(accounts, amount, Some(decimals)) + shared::transfer::process_transfer(accounts, amount, Some(decimals), signer_is_validated) }