diff --git a/program-libs/token-interface/src/state/token/borsh.rs b/program-libs/token-interface/src/state/token/borsh.rs index 90d3360a52..a4305d85d4 100644 --- a/program-libs/token-interface/src/state/token/borsh.rs +++ b/program-libs/token-interface/src/state/token/borsh.rs @@ -135,8 +135,10 @@ impl AnchorDeserialize for Token { Option::>::deserialize_reader(buf).unwrap_or_default(); (account_type, extensions) } else { - // Account type byte present but not Token - store it but no extensions - (account_type, None) + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Account type does not match Token account", + )); } } else { // No account_type byte - base SPL token account without extensions diff --git a/program-libs/token-interface/tests/cross_deserialization.rs b/program-libs/token-interface/tests/cross_deserialization.rs index 6d25d85444..3c35e17f34 100644 --- a/program-libs/token-interface/tests/cross_deserialization.rs +++ b/program-libs/token-interface/tests/cross_deserialization.rs @@ -192,25 +192,11 @@ fn test_mint_bytes_borsh_as_ctoken() { let mint = create_test_mint(); let mint_bytes = mint.try_to_vec().unwrap(); - // Try to deserialize Mint bytes as Token + // Mint has account_type = ACCOUNT_TYPE_MINT (1) at byte 165. + // Borsh deserialization of Token now rejects non-Token account_type bytes. let result = Token::try_from_slice(&mint_bytes); - // Borsh deserialization is lenient, but checked deserialization should detect the wrong type - match result { - Ok(token) => { - // Borsh is lenient and may succeed, but is_token_account() check should fail - // because Mint has account_type = ACCOUNT_TYPE_MINT (1), not ACCOUNT_TYPE_TOKEN_ACCOUNT (2) - assert!( - !token.is_token_account(), - "Mint bytes deserialized as Token should fail is_token_account() check" - ); - assert_eq!( - token.account_type(), - ACCOUNT_TYPE_MINT, - "Mint bytes should retain ACCOUNT_TYPE_MINT discriminator" - ); - } - Err(_) => { - // Also acceptable - deserialization failure - } - } + assert!( + result.is_err(), + "Mint bytes should fail borsh deserialization as Token due to account_type mismatch" + ); } diff --git a/program-libs/token-interface/tests/token/borsh_deser.rs b/program-libs/token-interface/tests/token/borsh_deser.rs new file mode 100644 index 0000000000..13dc3d65c9 --- /dev/null +++ b/program-libs/token-interface/tests/token/borsh_deser.rs @@ -0,0 +1,137 @@ +//! Tests that borsh deserialization of Token rejects invalid account_type bytes. +//! +//! The fix in borsh.rs ensures that when an account_type byte is present (byte 165) +//! but does not equal ACCOUNT_TYPE_TOKEN_ACCOUNT (2), deserialization returns an error +//! instead of silently accepting the wrong type. + +use borsh::{BorshDeserialize, BorshSerialize}; +use light_compressed_account::Pubkey; +use light_compressible::{compression_info::CompressionInfo, rent::RentConfig}; +use light_token_interface::state::{ + AccountState, CompressibleExtension, ExtensionStruct, Token, ACCOUNT_TYPE_TOKEN_ACCOUNT, +}; + +/// Helper: create a valid Token with extensions, serialize it, then +/// tamper the account_type byte at offset 165 to the given value. +fn serialize_token_with_account_type(account_type_byte: u8) -> Vec { + let token = Token { + mint: Pubkey::new_from_array([1; 32]), + owner: Pubkey::new_from_array([2; 32]), + amount: 1000, + delegate: None, + state: AccountState::Initialized, + is_native: None, + delegated_amount: 0, + close_authority: None, + account_type: ACCOUNT_TYPE_TOKEN_ACCOUNT, + extensions: Some(vec![ExtensionStruct::Compressible(CompressibleExtension { + decimals_option: 1, + decimals: 6, + compression_only: false, + is_ata: 0, + info: CompressionInfo { + config_account_version: 1, + compress_to_pubkey: 0, + account_version: 3, + lamports_per_write: 100, + compression_authority: [3u8; 32], + rent_sponsor: [4u8; 32], + last_claimed_slot: 100, + rent_exemption_paid: 0, + _reserved: 0, + rent_config: RentConfig { + base_rent: 0, + compression_cost: 0, + lamports_per_byte_per_epoch: 0, + max_funded_epochs: 0, + max_top_up: 0, + }, + }, + })]), + }; + + let mut bytes = token.try_to_vec().unwrap(); + // Tamper byte 165 (account_type) to the requested value + bytes[165] = account_type_byte; + bytes +} + +#[test] +fn test_borsh_deser_rejects_mint_account_type() { + // account_type = 1 (ACCOUNT_TYPE_MINT) should be rejected + let bytes = serialize_token_with_account_type(1); + let result = Token::try_from_slice(&bytes); + assert!( + result.is_err(), + "Borsh deserialization should reject account_type=1 (Mint) for Token" + ); +} + +#[test] +fn test_borsh_deser_rejects_zero_account_type() { + // account_type = 0 (uninitialized / invalid) should be rejected + let bytes = serialize_token_with_account_type(0); + let result = Token::try_from_slice(&bytes); + assert!( + result.is_err(), + "Borsh deserialization should reject account_type=0 for Token" + ); +} + +#[test] +fn test_borsh_deser_rejects_arbitrary_account_type() { + // account_type = 255 (arbitrary invalid value) should be rejected + let bytes = serialize_token_with_account_type(255); + let result = Token::try_from_slice(&bytes); + assert!( + result.is_err(), + "Borsh deserialization should reject account_type=255 for Token" + ); +} + +#[test] +fn test_borsh_deser_accepts_valid_token_account_type() { + // account_type = 2 (ACCOUNT_TYPE_TOKEN_ACCOUNT) should succeed + let bytes = serialize_token_with_account_type(ACCOUNT_TYPE_TOKEN_ACCOUNT); + let result = Token::try_from_slice(&bytes); + assert!( + result.is_ok(), + "Borsh deserialization should accept account_type=2 (Token)" + ); +} + +#[test] +fn test_borsh_deser_accepts_base_token_without_extensions() { + // A 165-byte base SPL token (no account_type byte) should still deserialize fine + let token = Token { + mint: Pubkey::new_from_array([1; 32]), + owner: Pubkey::new_from_array([2; 32]), + amount: 500, + delegate: None, + state: AccountState::Initialized, + is_native: None, + delegated_amount: 0, + close_authority: None, + account_type: ACCOUNT_TYPE_TOKEN_ACCOUNT, + extensions: None, + }; + + let bytes = token.try_to_vec().unwrap(); + assert_eq!(bytes.len(), 165, "Base token should be 165 bytes"); + + let deserialized = Token::try_from_slice(&bytes).expect("Should deserialize base token"); + assert_eq!(deserialized, token); +} + +#[test] +fn test_borsh_deser_error_message_on_invalid_account_type() { + let bytes = serialize_token_with_account_type(1); + let err = Token::try_from_slice(&bytes).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!( + err.to_string() + .contains("Account type does not match Token account"), + "Error message should indicate account type mismatch, got: {}", + err + ); +} diff --git a/program-libs/token-interface/tests/token/mod.rs b/program-libs/token-interface/tests/token/mod.rs index 226906c1f6..25c453e16f 100644 --- a/program-libs/token-interface/tests/token/mod.rs +++ b/program-libs/token-interface/tests/token/mod.rs @@ -1,3 +1,4 @@ +pub mod borsh_deser; pub mod failing; pub mod randomized_solana_token; pub mod size;