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 TokenMintMetadata {
29 pub mint: Mint,
31 pub program_id: Pubkey,
33 pub transfer_fee: Option<TransferFee>,
35 pub transfer_hook_program_id: Option<Pubkey>,
38}
39
40pub fn get_token_mint_metadata(
55 mint_pubkey: Pubkey,
56 mint_account: &Account,
57 epoch: u64,
58) -> Result<TokenMintMetadata, TokenError> {
59 let unpacked = StateWithExtensions::<Mint>::unpack(&mint_account.data).map_err(|e| {
60 TokenError::MintDecodeFailed {
61 mint: mint_pubkey,
62 reason: format!("StateWithExtensions::unpack: {e}"),
63 }
64 })?;
65
66 let transfer_fee = unpacked
67 .get_extension::<TransferFeeConfig>()
68 .ok()
69 .map(|cfg| {
70 let fee = cfg.get_epoch_fee(epoch);
71 TransferFee {
72 fee_bps: fee.transfer_fee_basis_points.into(),
73 max_fee: fee.maximum_fee.into(),
74 }
75 });
76
77 let transfer_hook_program_id = unpacked
78 .get_extension::<TransferHook>()
79 .ok()
80 .and_then(|hook| Option::<Pubkey>::from(hook.program_id));
81
82 Ok(TokenMintMetadata {
83 mint: unpacked.base,
84 program_id: mint_account.owner,
85 transfer_fee,
86 transfer_hook_program_id,
87 })
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92#[non_exhaustive]
93pub struct TransferHookInfo {
94 pub hook_program_id: Pubkey,
96}
97
98#[must_use]
109pub fn detect_transfer_hooks(state: &TokenAccountState) -> HashMap<Pubkey, TransferHookInfo> {
110 let mut out = HashMap::new();
111 for (mint_pubkey, entry) in &state.mints {
112 if let Ok(unpacked) = StateWithExtensions::<Mint>::unpack(&entry.mint_account.data) {
113 if let Ok(hook) = unpacked.get_extension::<TransferHook>() {
114 if let Some(program_id) = Option::<Pubkey>::from(hook.program_id) {
115 out.insert(
116 *mint_pubkey,
117 TransferHookInfo {
118 hook_program_id: program_id,
119 },
120 );
121 }
122 }
123 }
124 }
125 out
126}
127
128pub fn reject_transfer_hook_mints(state: &TokenAccountState) -> Result<(), TokenError> {
138 let hooks = detect_transfer_hooks(state);
139 let mut sorted: Vec<(Pubkey, TransferHookInfo)> = hooks.into_iter().collect();
140 sorted.sort_by_key(|(pubkey, _)| *pubkey);
141 if let Some((mint, info)) = sorted.into_iter().next() {
142 return Err(TokenError::TransferHookDetected {
143 mint,
144 program: info.hook_program_id,
145 });
146 }
147 Ok(())
148}
149
150#[cfg(test)]
151mod tests {
152 use solana_program_pack::Pack;
153 use spl_token::state::Mint as ClassicMint;
154
155 use super::*;
156 use crate::state::{MintAndAta, TokenAccountState};
157
158 fn make_classic_mint_account(decimals: u8) -> Account {
159 let mint = ClassicMint {
160 mint_authority: spl_token::solana_program::program_option::COption::None,
161 supply: 0,
162 decimals,
163 is_initialized: true,
164 freeze_authority: spl_token::solana_program::program_option::COption::None,
165 };
166 let mut data = vec![0u8; ClassicMint::LEN];
167 ClassicMint::pack(mint, &mut data).unwrap();
168 Account {
169 lamports: 1_000_000,
170 data,
171 owner: spl_token::ID,
172 executable: false,
173 rent_epoch: 0,
174 }
175 }
176
177 #[test]
178 fn classic_mint_decodes_with_no_transfer_fee() {
179 let parsed =
180 get_token_mint_metadata(Pubkey::new_unique(), &make_classic_mint_account(9), 100)
181 .unwrap();
182 assert_eq!(parsed.mint.decimals, 9);
183 assert_eq!(parsed.program_id, spl_token::ID);
184 assert!(parsed.transfer_fee.is_none());
185 assert!(parsed.transfer_hook_program_id.is_none());
186 }
187
188 #[test]
189 fn malformed_mint_data_returns_decode_error() {
190 let mint_pubkey = Pubkey::new_unique();
191 let account = Account {
192 lamports: 0,
193 data: vec![0xFF; 32],
194 owner: spl_token::ID,
195 executable: false,
196 rent_epoch: 0,
197 };
198 let err = get_token_mint_metadata(mint_pubkey, &account, 100).unwrap_err();
199 assert!(matches!(err, TokenError::MintDecodeFailed { mint, .. } if mint == mint_pubkey));
200 }
201
202 fn state_with_one_mint(mint: Pubkey, account: Account) -> TokenAccountState {
203 let mut mints = HashMap::new();
204 mints.insert(
205 mint,
206 MintAndAta {
207 mint_account: account,
208 ata_address: Pubkey::new_unique(),
209 ata_account: None,
210 },
211 );
212 TokenAccountState {
213 owner: Pubkey::new_unique(),
214 mints,
215 }
216 }
217
218 #[test]
219 fn detect_transfer_hooks_empty_for_classic_mint() {
220 let state = state_with_one_mint(Pubkey::new_unique(), make_classic_mint_account(9));
221 assert!(detect_transfer_hooks(&state).is_empty());
222 }
223}