use hopper_runtime::error::ProgramError;
pub const EXT_TRANSFER_FEE_CONFIG: u16 = 1;
pub const EXT_TRANSFER_FEE_AMOUNT: u16 = 2;
pub const EXT_MINT_CLOSE_AUTHORITY: u16 = 3;
pub const EXT_CONFIDENTIAL_TRANSFER_MINT: u16 = 4;
pub const EXT_CONFIDENTIAL_TRANSFER_ACCOUNT: u16 = 5;
pub const EXT_DEFAULT_ACCOUNT_STATE: u16 = 6;
pub const EXT_IMMUTABLE_OWNER: u16 = 7;
pub const EXT_MEMO_TRANSFER: u16 = 8;
pub const EXT_NON_TRANSFERABLE: u16 = 9;
pub const EXT_INTEREST_BEARING: u16 = 10;
pub const EXT_CPI_GUARD: u16 = 11;
pub const EXT_PERMANENT_DELEGATE: u16 = 12;
pub const EXT_TRANSFER_HOOK: u16 = 14;
pub const EXT_METADATA_POINTER: u16 = 18;
pub const EXT_TOKEN_METADATA: u16 = 19;
pub const EXT_GROUP_POINTER: u16 = 20;
pub const EXT_GROUP_MEMBER_POINTER: u16 = 22;
pub const MINT_BASE_SIZE: usize = 82;
pub const TOKEN_ACCOUNT_BASE_SIZE: usize = 165;
pub const ACCOUNT_TYPE_OFFSET: usize = TOKEN_ACCOUNT_BASE_SIZE;
pub const TLV_OFFSET: usize = ACCOUNT_TYPE_OFFSET + 1;
pub const ACCOUNT_TYPE_MINT: u8 = 1;
pub const ACCOUNT_TYPE_TOKEN: u8 = 2;
#[inline(always)]
pub fn find_extension_data(data: &[u8], base_size: usize, ext_type: u16) -> Option<&[u8]> {
if data.len() <= TLV_OFFSET {
return None;
}
let expected = match base_size {
MINT_BASE_SIZE => ACCOUNT_TYPE_MINT,
TOKEN_ACCOUNT_BASE_SIZE => ACCOUNT_TYPE_TOKEN,
_ => return None,
};
let kind = data[ACCOUNT_TYPE_OFFSET];
if kind != expected && kind != 0 {
return None;
}
let mut offset = TLV_OFFSET;
while offset + 4 <= data.len() {
let ty = u16::from_le_bytes([data[offset], data[offset + 1]]);
let len = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize;
let value_start = offset + 4;
let value_end = value_start.checked_add(len)?;
if value_end > data.len() {
return None; }
if ty == ext_type {
return Some(&data[value_start..value_end]);
}
if ty == 0 && len == 0 {
return None;
}
offset = value_end;
}
None
}
#[inline(always)]
pub fn mint_has_extension(mint_data: &[u8], ext_type: u16) -> bool {
find_extension_data(mint_data, MINT_BASE_SIZE, ext_type).is_some()
}
#[inline(always)]
pub fn token_has_extension(token_data: &[u8], ext_type: u16) -> bool {
find_extension_data(token_data, TOKEN_ACCOUNT_BASE_SIZE, ext_type).is_some()
}
#[inline(always)]
pub fn check_no_transfer_fee(mint_data: &[u8]) -> Result<(), ProgramError> {
if mint_has_extension(mint_data, EXT_TRANSFER_FEE_CONFIG) {
return Err(ProgramError::InvalidAccountData);
}
Ok(())
}
#[inline(always)]
pub fn check_no_permanent_delegate(mint_data: &[u8]) -> Result<(), ProgramError> {
if mint_has_extension(mint_data, EXT_PERMANENT_DELEGATE) {
return Err(ProgramError::InvalidAccountData);
}
Ok(())
}
#[inline(always)]
pub fn check_no_confidential_transfer(mint_data: &[u8]) -> Result<(), ProgramError> {
if mint_has_extension(mint_data, EXT_CONFIDENTIAL_TRANSFER_MINT) {
return Err(ProgramError::InvalidAccountData);
}
Ok(())
}
#[inline(always)]
pub fn check_transferable(mint_data: &[u8]) -> Result<(), ProgramError> {
if mint_has_extension(mint_data, EXT_NON_TRANSFERABLE) {
return Err(ProgramError::InvalidAccountData);
}
Ok(())
}
#[inline(always)]
pub fn check_no_transfer_hook(mint_data: &[u8]) -> Result<(), ProgramError> {
if mint_has_extension(mint_data, EXT_TRANSFER_HOOK) {
return Err(ProgramError::InvalidAccountData);
}
Ok(())
}
#[inline(always)]
pub fn check_safe_token_2022_mint(mint_data: &[u8]) -> Result<(), ProgramError> {
check_no_transfer_fee(mint_data)?;
check_no_permanent_delegate(mint_data)?;
check_no_confidential_transfer(mint_data)?;
check_transferable(mint_data)?;
check_no_transfer_hook(mint_data)?;
Ok(())
}
pub struct TransferFeeConfig {
pub fee_bps: u16,
pub maximum_fee: u64,
}
#[inline(always)]
pub fn read_transfer_fee_config(mint_data: &[u8]) -> Result<TransferFeeConfig, ProgramError> {
let ext = find_extension_data(mint_data, MINT_BASE_SIZE, EXT_TRANSFER_FEE_CONFIG)
.ok_or(ProgramError::InvalidAccountData)?;
if ext.len() < 96 {
return Err(ProgramError::InvalidAccountData);
}
let newer_max_fee = u64::from_le_bytes([
ext[86], ext[87], ext[88], ext[89], ext[90], ext[91], ext[92], ext[93],
]);
let newer_fee_bps = u16::from_le_bytes([ext[94], ext[95]]);
Ok(TransferFeeConfig {
fee_bps: newer_fee_bps,
maximum_fee: newer_max_fee,
})
}
pub struct TransferHook<'a> {
pub authority: &'a [u8; 32],
pub program_id: &'a [u8; 32],
}
#[inline(always)]
pub fn read_transfer_hook(mint_data: &[u8]) -> Result<Option<TransferHook<'_>>, ProgramError> {
let Some(ext) = find_extension_data(mint_data, MINT_BASE_SIZE, EXT_TRANSFER_HOOK) else {
return Ok(None);
};
if ext.len() < 64 {
return Err(ProgramError::InvalidAccountData);
}
let authority: &[u8; 32] = ext[0..32].try_into().unwrap();
let program_id: &[u8; 32] = ext[32..64].try_into().unwrap();
Ok(Some(TransferHook {
authority,
program_id,
}))
}
#[inline(always)]
pub fn check_transfer_hook_program(
mint_data: &[u8],
expected_program_id: &[u8; 32],
) -> Result<(), ProgramError> {
match read_transfer_hook(mint_data)? {
Some(hook) if hook.program_id == expected_program_id => Ok(()),
Some(_) | None => Err(ProgramError::InvalidAccountData),
}
}
#[cfg(test)]
mod tests {
extern crate alloc;
use super::*;
use alloc::vec;
use alloc::vec::Vec;
fn sample_mint_with_extensions(exts: &[(u16, &[u8])]) -> Vec<u8> {
let mut data = vec![0u8; ACCOUNT_TYPE_OFFSET]; data.push(ACCOUNT_TYPE_MINT);
for (ext_type, ext_value) in exts {
data.extend_from_slice(&ext_type.to_le_bytes());
data.extend_from_slice(&(ext_value.len() as u16).to_le_bytes());
data.extend_from_slice(ext_value);
}
debug_assert!(data.len() > TLV_OFFSET);
data
}
fn sample_mint_with_extension(ext_type: u16, ext_value: &[u8]) -> Vec<u8> {
sample_mint_with_extensions(&[(ext_type, ext_value)])
}
#[test]
fn offset_constants_match_authoritative_spec() {
assert_eq!(MINT_BASE_SIZE, 82);
assert_eq!(TOKEN_ACCOUNT_BASE_SIZE, 165);
assert_eq!(ACCOUNT_TYPE_OFFSET, 165);
assert_eq!(TLV_OFFSET, 166);
assert_eq!(ACCOUNT_TYPE_MINT, 1);
assert_eq!(ACCOUNT_TYPE_TOKEN, 2);
}
#[test]
fn tlv_payload_lives_at_byte_166() {
let data = sample_mint_with_extension(EXT_NON_TRANSFERABLE, &[]);
assert_eq!(
u16::from_le_bytes([data[TLV_OFFSET], data[TLV_OFFSET + 1]]),
EXT_NON_TRANSFERABLE,
);
assert_eq!(data[84], 0);
assert_eq!(data[85], 0);
}
#[test]
fn no_extensions_passes_all_checks() {
let data = vec![0u8; MINT_BASE_SIZE];
assert!(check_safe_token_2022_mint(&data).is_ok());
}
#[test]
fn detects_transfer_fee() {
let data = sample_mint_with_extension(EXT_TRANSFER_FEE_CONFIG, &[0u8; 108]);
assert!(mint_has_extension(&data, EXT_TRANSFER_FEE_CONFIG));
assert!(check_no_transfer_fee(&data).is_err());
assert!(check_safe_token_2022_mint(&data).is_err());
}
#[test]
fn detects_permanent_delegate() {
let data = sample_mint_with_extension(EXT_PERMANENT_DELEGATE, &[0u8; 32]);
assert!(check_no_permanent_delegate(&data).is_err());
assert!(check_safe_token_2022_mint(&data).is_err());
}
#[test]
fn detects_confidential_transfer() {
let data = sample_mint_with_extension(EXT_CONFIDENTIAL_TRANSFER_MINT, &[0u8; 64]);
assert!(check_no_confidential_transfer(&data).is_err());
}
#[test]
fn detects_non_transferable() {
let data = sample_mint_with_extension(EXT_NON_TRANSFERABLE, &[]);
assert!(check_transferable(&data).is_err());
}
#[test]
fn detects_transfer_hook() {
let data = sample_mint_with_extension(EXT_TRANSFER_HOOK, &[0u8; 64]);
assert!(check_no_transfer_hook(&data).is_err());
}
#[test]
fn safe_with_benign_extensions_only() {
let data = sample_mint_with_extensions(&[
(EXT_METADATA_POINTER, &[0u8; 64]),
(EXT_TOKEN_METADATA, &[0u8; 100]),
]);
assert!(check_safe_token_2022_mint(&data).is_ok());
}
#[test]
fn finds_second_extension() {
let data = sample_mint_with_extensions(&[
(EXT_METADATA_POINTER, &[0u8; 64]),
(EXT_PERMANENT_DELEGATE, &[0u8; 32]),
]);
assert!(mint_has_extension(&data, EXT_PERMANENT_DELEGATE));
assert!(check_no_permanent_delegate(&data).is_err());
}
#[test]
fn read_transfer_fee_config_parses_correctly() {
let mut ext_value = vec![0u8; 96];
let max_fee = 1_000_000u64;
ext_value[86..94].copy_from_slice(&max_fee.to_le_bytes());
ext_value[94..96].copy_from_slice(&250u16.to_le_bytes());
let data = sample_mint_with_extension(EXT_TRANSFER_FEE_CONFIG, &ext_value);
let fee = read_transfer_fee_config(&data).unwrap();
assert_eq!(fee.fee_bps, 250);
assert_eq!(fee.maximum_fee, 1_000_000);
}
#[test]
fn read_transfer_fee_config_rejects_missing() {
let data = vec![0u8; MINT_BASE_SIZE];
assert!(read_transfer_fee_config(&data).is_err());
}
#[test]
fn truncated_tlv_returns_none() {
let mut data = vec![0u8; ACCOUNT_TYPE_OFFSET];
data.push(ACCOUNT_TYPE_MINT);
data.extend_from_slice(&EXT_TRANSFER_FEE_CONFIG.to_le_bytes());
data.extend_from_slice(&200u16.to_le_bytes()); data.extend_from_slice(&[0u8; 10]); assert!(!mint_has_extension(&data, EXT_TRANSFER_FEE_CONFIG));
}
#[test]
fn rejects_reading_mint_extension_out_of_token_account() {
let mut data = vec![0u8; ACCOUNT_TYPE_OFFSET];
data.push(ACCOUNT_TYPE_TOKEN);
data.extend_from_slice(&EXT_TRANSFER_FEE_AMOUNT.to_le_bytes());
data.extend_from_slice(&0u16.to_le_bytes());
assert!(find_extension_data(&data, MINT_BASE_SIZE, EXT_TRANSFER_FEE_AMOUNT).is_none());
assert!(
find_extension_data(&data, TOKEN_ACCOUNT_BASE_SIZE, EXT_TRANSFER_FEE_AMOUNT).is_some()
);
}
#[test]
fn unknown_base_size_is_rejected() {
let data = sample_mint_with_extension(EXT_NON_TRANSFERABLE, &[]);
assert!(find_extension_data(&data, 42, EXT_NON_TRANSFERABLE).is_none());
assert!(find_extension_data(&data, 0, EXT_NON_TRANSFERABLE).is_none());
}
#[test]
fn read_transfer_hook_parses_authority_and_program_id() {
let mut ext_value = vec![0u8; 64];
for i in 0..32 {
ext_value[i] = 0xAA;
}
for i in 32..64 {
ext_value[i] = 0xBB;
}
let data = sample_mint_with_extension(EXT_TRANSFER_HOOK, &ext_value);
let hook = read_transfer_hook(&data).unwrap().unwrap();
assert_eq!(hook.authority, &[0xAA; 32]);
assert_eq!(hook.program_id, &[0xBB; 32]);
}
#[test]
fn read_transfer_hook_returns_none_when_absent() {
let data = vec![0u8; MINT_BASE_SIZE];
assert!(matches!(read_transfer_hook(&data), Ok(None)));
}
#[test]
fn read_transfer_hook_rejects_truncated_extension() {
let data = sample_mint_with_extension(EXT_TRANSFER_HOOK, &[0u8; 32]);
assert!(read_transfer_hook(&data).is_err());
}
#[test]
fn check_transfer_hook_program_accepts_match() {
let mut ext_value = vec![0u8; 64];
ext_value[32..64].copy_from_slice(&[0xCC; 32]);
let data = sample_mint_with_extension(EXT_TRANSFER_HOOK, &ext_value);
assert!(check_transfer_hook_program(&data, &[0xCC; 32]).is_ok());
}
#[test]
fn check_transfer_hook_program_rejects_mismatch() {
let mut ext_value = vec![0u8; 64];
ext_value[32..64].copy_from_slice(&[0xCC; 32]);
let data = sample_mint_with_extension(EXT_TRANSFER_HOOK, &ext_value);
assert!(check_transfer_hook_program(&data, &[0xDD; 32]).is_err());
}
#[test]
fn check_transfer_hook_program_rejects_missing_extension() {
let data = vec![0u8; MINT_BASE_SIZE];
assert!(check_transfer_hook_program(&data, &[0; 32]).is_err());
}
}