Skip to main content

solana_token_toolkit/
mint.rs

1//! Tier 2 — Token-2022 mint extension utilities.
2
3use 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/// Epoch-aware transfer fee summary for a Token-2022 mint.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct TransferFee {
20    /// Fee in basis points (1 bps = 0.01%).
21    pub fee_bps: u16,
22    /// Maximum fee in raw token units regardless of transfer size.
23    pub max_fee: u64,
24}
25
26/// Parsed mint state with metadata fields useful for client code.
27#[derive(Debug, Clone)]
28pub struct TokenMintMetadata {
29    /// Unpacked mint base state.
30    pub mint: Mint,
31    /// The token program that owns this mint.
32    pub program_id: Pubkey,
33    /// Transfer fee configuration if the mint has the TransferFeeConfig extension.
34    pub transfer_fee: Option<TransferFee>,
35    /// Transfer hook program ID if the mint has the TransferHook extension with
36    /// a non-default program. None otherwise.
37    pub transfer_hook_program_id: Option<Pubkey>,
38}
39
40/// Parse a mint account and extract metadata.
41///
42/// # Example
43///
44/// ```no_run
45/// # use solana_token_toolkit::{get_token_mint_metadata, TokenError};
46/// # use solana_account::Account;
47/// # use solana_pubkey::Pubkey;
48/// # fn run(mint_account: Account) -> Result<(), TokenError> {
49/// let parsed = get_token_mint_metadata(Pubkey::new_unique(), &mint_account, 100)?;
50/// # let _ = parsed;
51/// # Ok(())
52/// # }
53/// ```
54pub 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/// Information about a transfer hook attached to a Token-2022 mint.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92#[non_exhaustive]
93pub struct TransferHookInfo {
94    /// The program ID that the mint's transfer hook calls into.
95    pub hook_program_id: Pubkey,
96}
97
98/// Detect transfer hooks on every mint in the given state.
99///
100/// # Example
101///
102/// ```no_run
103/// # use solana_token_toolkit::{detect_transfer_hooks, TokenAccountState};
104/// # use solana_pubkey::Pubkey;
105/// let hooks = detect_transfer_hooks(&TokenAccountState::empty(Pubkey::new_unique()));
106/// assert!(hooks.is_empty());
107/// ```
108#[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
128/// Strict-mode policy: return the lowest-pubkey transfer-hook detection as an error.
129///
130/// # Example
131///
132/// ```no_run
133/// # use solana_token_toolkit::{reject_transfer_hook_mints, TokenAccountState};
134/// # use solana_pubkey::Pubkey;
135/// reject_transfer_hook_mints(&TokenAccountState::empty(Pubkey::new_unique())).unwrap();
136/// ```
137pub 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}