solana_token_toolkit/
mint.rs1use std::collections::HashMap;
4
5use solana_account::Account;
6use solana_pubkey::Pubkey;
7use spl_token_2022_interface::{
8 extension::{
9 transfer_fee::TransferFeeConfig, transfer_hook::TransferHook, BaseStateWithExtensions,
10 StateWithExtensions,
11 },
12 state::Mint,
13};
14
15use crate::{state::TokenAccountState, TokenError};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct TransferFee {
20 pub fee_bps: u16,
22 pub max_fee: u64,
24}
25
26#[derive(Debug, Clone)]
28pub struct TokenMintWithFee {
29 pub mint: Mint,
31 pub transfer_fee: Option<TransferFee>,
33}
34
35pub fn get_token_mint_and_transfer_fee(
50 mint_pubkey: Pubkey,
51 mint_account: &Account,
52 epoch: u64,
53) -> Result<TokenMintWithFee, TokenError> {
54 let unpacked = StateWithExtensions::<Mint>::unpack(&mint_account.data).map_err(|e| {
55 TokenError::MintDecodeFailed {
56 mint: mint_pubkey,
57 reason: format!("StateWithExtensions::unpack: {e}"),
58 }
59 })?;
60
61 let transfer_fee = unpacked
62 .get_extension::<TransferFeeConfig>()
63 .ok()
64 .map(|cfg| {
65 let fee = cfg.get_epoch_fee(epoch);
66 TransferFee {
67 fee_bps: fee.transfer_fee_basis_points.into(),
68 max_fee: fee.maximum_fee.into(),
69 }
70 });
71
72 Ok(TokenMintWithFee {
73 mint: unpacked.base,
74 transfer_fee,
75 })
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[non_exhaustive]
81pub struct TransferHookInfo {
82 pub hook_program_id: Pubkey,
84}
85
86#[must_use]
97pub fn detect_transfer_hooks(state: &TokenAccountState) -> HashMap<Pubkey, TransferHookInfo> {
98 let mut out = HashMap::new();
99 for (mint_pubkey, entry) in &state.mints {
100 if let Ok(unpacked) = StateWithExtensions::<Mint>::unpack(&entry.mint_account.data) {
101 if let Ok(hook) = unpacked.get_extension::<TransferHook>() {
102 if let Some(program_id) = Option::<Pubkey>::from(hook.program_id) {
103 out.insert(
104 *mint_pubkey,
105 TransferHookInfo {
106 hook_program_id: program_id,
107 },
108 );
109 }
110 }
111 }
112 }
113 out
114}
115
116pub fn reject_transfer_hook_mints(state: &TokenAccountState) -> Result<(), TokenError> {
126 let hooks = detect_transfer_hooks(state);
127 let mut sorted: Vec<(Pubkey, TransferHookInfo)> = hooks.into_iter().collect();
128 sorted.sort_by_key(|(pubkey, _)| *pubkey);
129 if let Some((mint, info)) = sorted.into_iter().next() {
130 return Err(TokenError::TransferHookDetected {
131 mint,
132 program: info.hook_program_id,
133 });
134 }
135 Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140 use solana_program_pack::Pack;
141 use spl_token::state::Mint as ClassicMint;
142
143 use super::*;
144 use crate::state::{MintAndAta, TokenAccountState};
145
146 fn make_classic_mint_account(decimals: u8) -> Account {
147 let mint = ClassicMint {
148 mint_authority: spl_token::solana_program::program_option::COption::None,
149 supply: 0,
150 decimals,
151 is_initialized: true,
152 freeze_authority: spl_token::solana_program::program_option::COption::None,
153 };
154 let mut data = vec![0u8; ClassicMint::LEN];
155 ClassicMint::pack(mint, &mut data).unwrap();
156 Account {
157 lamports: 1_000_000,
158 data,
159 owner: spl_token::ID,
160 executable: false,
161 rent_epoch: 0,
162 }
163 }
164
165 #[test]
166 fn classic_mint_decodes_with_no_transfer_fee() {
167 let parsed = get_token_mint_and_transfer_fee(
168 Pubkey::new_unique(),
169 &make_classic_mint_account(9),
170 100,
171 )
172 .unwrap();
173 assert_eq!(parsed.mint.decimals, 9);
174 assert!(parsed.transfer_fee.is_none());
175 }
176
177 #[test]
178 fn malformed_mint_data_returns_decode_error() {
179 let mint_pubkey = Pubkey::new_unique();
180 let account = Account {
181 lamports: 0,
182 data: vec![0xFF; 32],
183 owner: spl_token::ID,
184 executable: false,
185 rent_epoch: 0,
186 };
187 let err = get_token_mint_and_transfer_fee(mint_pubkey, &account, 100).unwrap_err();
188 assert!(matches!(err, TokenError::MintDecodeFailed { mint, .. } if mint == mint_pubkey));
189 }
190
191 fn state_with_one_mint(mint: Pubkey, account: Account) -> TokenAccountState {
192 let mut mints = HashMap::new();
193 mints.insert(
194 mint,
195 MintAndAta {
196 mint_account: account,
197 ata_address: Pubkey::new_unique(),
198 ata_account: None,
199 },
200 );
201 TokenAccountState {
202 owner: Pubkey::new_unique(),
203 mints,
204 }
205 }
206
207 #[test]
208 fn detect_transfer_hooks_empty_for_classic_mint() {
209 let state = state_with_one_mint(Pubkey::new_unique(), make_classic_mint_account(9));
210 assert!(detect_transfer_hooks(&state).is_empty());
211 }
212
213 }