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/// A parsed mint plus its current transfer fee (if any).
27#[derive(Debug, Clone)]
28pub struct TokenMintWithFee {
29    /// The unpacked mint base state.
30    pub mint: Mint,
31    /// `Some` if the mint has the TransferFee extension, `None` otherwise.
32    pub transfer_fee: Option<TransferFee>,
33}
34
35/// Parse a mint account and resolve its transfer fee for the given epoch.
36///
37/// # Example
38///
39/// ```no_run
40/// # use solana_token_toolkit::{get_token_mint_and_transfer_fee, TokenError};
41/// # use solana_account::Account;
42/// # use solana_pubkey::Pubkey;
43/// # fn run(mint_account: Account) -> Result<(), TokenError> {
44/// let parsed = get_token_mint_and_transfer_fee(Pubkey::new_unique(), &mint_account, 100)?;
45/// # let _ = parsed;
46/// # Ok(())
47/// # }
48/// ```
49pub 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/// Information about a transfer hook attached to a Token-2022 mint.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[non_exhaustive]
81pub struct TransferHookInfo {
82    /// The program ID that the mint's transfer hook calls into.
83    pub hook_program_id: Pubkey,
84}
85
86/// Detect transfer hooks on every mint in the given state.
87///
88/// # Example
89///
90/// ```no_run
91/// # use solana_token_toolkit::{detect_transfer_hooks, TokenAccountState};
92/// # use solana_pubkey::Pubkey;
93/// let hooks = detect_transfer_hooks(&TokenAccountState::empty(Pubkey::new_unique()));
94/// assert!(hooks.is_empty());
95/// ```
96#[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
116/// Strict-mode policy: return the lowest-pubkey transfer-hook detection as an error.
117///
118/// # Example
119///
120/// ```no_run
121/// # use solana_token_toolkit::{reject_transfer_hook_mints, TokenAccountState};
122/// # use solana_pubkey::Pubkey;
123/// reject_transfer_hook_mints(&TokenAccountState::empty(Pubkey::new_unique())).unwrap();
124/// ```
125pub 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    // Token-2022 mint extension construction is deferred to v0.1.1.
214    // The spl-token-2022-interface 2.x API for off-chain mint extension
215    // construction relies on crate-private `init_extension` /
216    // `init_account_type` methods (the public surface, `alloc_and_serialize`,
217    // requires a Solana program AccountInfo context). v0.1.1 will add
218    // LiteSVM-backed integration tests that create real Token-2022 mints
219    // via the on-chain program, which exercises both
220    // `get_token_mint_and_transfer_fee` and `detect_transfer_hooks` /
221    // `reject_transfer_hook_mints` end-to-end. See spec §6.4 amendment.
222}