use std::collections::HashMap;
use solana_account::Account;
use solana_program_pack::Pack;
use solana_pubkey::Pubkey;
use solana_signer::Signer;
use solana_token_toolkit::{
detect_transfer_hooks, get_token_mint_metadata, prepare_token_accounts, AtaCreateMode,
MintAndAta, MintIntent, TokenAccountIntent, TokenAccountPlanConfig, TokenAccountState,
TokenError, WrapSolStrategy,
};
use spl_token::state::{Account as SplTokenAccount, AccountState, Mint as SplMint};
use spl_token_interface::native_mint;
fn mint_account(token_program: Pubkey, decimals: u8) -> Account {
let mint = SplMint {
mint_authority: spl_token::solana_program::program_option::COption::None,
supply: 0,
decimals,
is_initialized: true,
freeze_authority: spl_token::solana_program::program_option::COption::None,
};
let mut data = vec![0u8; SplMint::LEN];
SplMint::pack(mint, &mut data).unwrap();
Account {
lamports: 1_000_000,
data,
owner: token_program,
executable: false,
rent_epoch: 0,
}
}
fn token_account_with_amount(amount: u64) -> Account {
token_account_with_amount_and_owner(amount, spl_token::ID)
}
fn token_account_with_amount_and_owner(amount: u64, owner: Pubkey) -> Account {
let token_acc = SplTokenAccount {
mint: Pubkey::new_unique(),
owner: Pubkey::new_unique(),
amount,
delegate: spl_token::solana_program::program_option::COption::None,
state: AccountState::Initialized,
is_native: spl_token::solana_program::program_option::COption::None,
delegated_amount: 0,
close_authority: spl_token::solana_program::program_option::COption::None,
};
let mut data = vec![0u8; SplTokenAccount::LEN];
SplTokenAccount::pack(token_acc, &mut data).unwrap();
Account {
lamports: 2_039_280,
data,
owner,
executable: false,
rent_epoch: 0,
}
}
fn state_with_entries(
owner: Pubkey,
entries: impl IntoIterator<Item = (Pubkey, MintAndAta)>,
) -> TokenAccountState {
TokenAccountState {
owner,
mints: entries.into_iter().collect(),
}
}
#[test]
fn classic_mint_parse_and_hook_detection_are_empty() {
let mint = Pubkey::new_unique();
let entry = MintAndAta {
mint_account: mint_account(spl_token::ID, 6),
ata_address: Pubkey::new_unique(),
ata_account: None,
};
let parsed = get_token_mint_metadata(mint, &entry.mint_account, 0).unwrap();
assert_eq!(parsed.mint.decimals, 6);
assert_eq!(parsed.program_id, spl_token::ID);
assert!(parsed.transfer_fee.is_none());
assert!(parsed.transfer_hook_program_id.is_none());
let state = state_with_entries(Pubkey::new_unique(), [(mint, entry)]);
assert!(detect_transfer_hooks(&state).is_empty());
}
#[test]
fn non_sol_ensure_ata_exists_builds_create_instruction_and_address() {
let owner = Pubkey::new_unique();
let mint = Pubkey::new_unique();
let ata = Pubkey::new_unique();
let state = state_with_entries(
owner,
[(
mint,
MintAndAta {
mint_account: mint_account(spl_token::ID, 9),
ata_address: ata,
ata_account: None,
},
)],
);
let intent = TokenAccountIntent {
mints: HashMap::from([(mint, MintIntent::EnsureAtaExists)]),
};
let plan =
prepare_token_accounts(&state, &intent, TokenAccountPlanConfig::with_rent(0)).unwrap();
assert_eq!(plan.create_instructions.len(), 1);
assert!(plan.cleanup_instructions.is_empty());
assert!(plan.additional_signers.is_empty());
assert_eq!(plan.token_account_addresses[&mint], ata);
}
#[test]
fn wsol_ata_missing_with_balance_creates_wrap_and_cleanup_flow() {
let owner = Pubkey::new_unique();
let ata = Pubkey::new_unique();
let state = state_with_entries(
owner,
[(
native_mint::ID,
MintAndAta {
mint_account: mint_account(spl_token::ID, native_mint::DECIMALS),
ata_address: ata,
ata_account: None,
},
)],
);
let intent = TokenAccountIntent {
mints: HashMap::from([(
native_mint::ID,
MintIntent::WithBalance {
lamports: 1_000_000,
},
)]),
};
let plan = prepare_token_accounts(
&state,
&intent,
TokenAccountPlanConfig::with_rent(2_039_280),
)
.unwrap();
assert_eq!(plan.create_instructions.len(), 3);
assert_eq!(plan.cleanup_instructions.len(), 1);
assert!(plan.additional_signers.is_empty());
assert_eq!(plan.token_account_addresses[&native_mint::ID], ata);
}
#[test]
fn wsol_existing_ata_only_tops_up_delta_and_does_not_cleanup() {
let owner = Pubkey::new_unique();
let ata = Pubkey::new_unique();
let state = state_with_entries(
owner,
[(
native_mint::ID,
MintAndAta {
mint_account: mint_account(spl_token::ID, native_mint::DECIMALS),
ata_address: ata,
ata_account: Some(token_account_with_amount(400_000)),
},
)],
);
let intent = TokenAccountIntent {
mints: HashMap::from([(
native_mint::ID,
MintIntent::WithBalance {
lamports: 1_000_000,
},
)]),
};
let plan = prepare_token_accounts(
&state,
&intent,
TokenAccountPlanConfig::with_rent(2_039_280),
)
.unwrap();
assert_eq!(plan.create_instructions.len(), 2);
assert!(plan.cleanup_instructions.is_empty());
assert!(plan.additional_signers.is_empty());
assert_eq!(plan.token_account_addresses[&native_mint::ID], ata);
}
#[test]
fn keypair_strategy_returns_ephemeral_signer_and_account_address() {
let owner = Pubkey::new_unique();
let state = state_with_entries(
owner,
[(
native_mint::ID,
MintAndAta {
mint_account: mint_account(spl_token::ID, native_mint::DECIMALS),
ata_address: Pubkey::new_unique(),
ata_account: None,
},
)],
);
let intent = TokenAccountIntent {
mints: HashMap::from([(
native_mint::ID,
MintIntent::WithBalance {
lamports: 1_000_000,
},
)]),
};
let plan = prepare_token_accounts(
&state,
&intent,
TokenAccountPlanConfig {
wsol_strategy: WrapSolStrategy::Keypair,
..TokenAccountPlanConfig::with_rent(2_039_280)
},
)
.unwrap();
assert_eq!(plan.create_instructions.len(), 2);
assert_eq!(plan.cleanup_instructions.len(), 1);
assert_eq!(plan.additional_signers.len(), 1);
assert_eq!(
plan.token_account_addresses[&native_mint::ID],
plan.additional_signers[0].pubkey()
);
}
#[test]
fn invalid_intents_return_typed_errors() {
let owner = Pubkey::new_unique();
let mint = Pubkey::new_unique();
let state = state_with_entries(
owner,
[(
mint,
MintAndAta {
mint_account: mint_account(spl_token::ID, 9),
ata_address: Pubkey::new_unique(),
ata_account: None,
},
)],
);
let non_sol_with_balance = TokenAccountIntent {
mints: HashMap::from([(mint, MintIntent::WithBalance { lamports: 1 })]),
};
let err = prepare_token_accounts(
&state,
&non_sol_with_balance,
TokenAccountPlanConfig::with_rent(0),
)
.unwrap_err();
assert!(matches!(err, TokenError::WithBalanceNotSupported(m) if m == mint));
let missing = Pubkey::new_unique();
let missing_intent = TokenAccountIntent {
mints: HashMap::from([(missing, MintIntent::EnsureAtaExists)]),
};
let err = prepare_token_accounts(
&state,
&missing_intent,
TokenAccountPlanConfig::with_rent(0),
)
.unwrap_err();
assert!(matches!(err, TokenError::MintNotFound(m) if m == missing));
}
#[test]
fn prepare_with_ata_legacy_emits_non_idempotent_create() {
let owner = Pubkey::new_unique();
let mint = Pubkey::new_unique();
let ata = Pubkey::new_unique();
let state = state_with_entries(
owner,
[(
mint,
MintAndAta {
mint_account: mint_account(spl_token::ID, 9),
ata_address: ata,
ata_account: None,
},
)],
);
let intent = TokenAccountIntent {
mints: HashMap::from([(mint, MintIntent::EnsureAtaExists)]),
};
let plan = prepare_token_accounts(
&state,
&intent,
TokenAccountPlanConfig {
ata_create_mode: AtaCreateMode::Legacy,
..TokenAccountPlanConfig::with_rent(0)
},
)
.unwrap();
assert_eq!(plan.create_instructions.len(), 1);
assert_eq!(plan.create_instructions[0].data, vec![0u8]);
}
#[test]
fn prepare_with_ata_idempotent_default_emits_idempotent_create() {
let owner = Pubkey::new_unique();
let mint = Pubkey::new_unique();
let ata = Pubkey::new_unique();
let state = state_with_entries(
owner,
[(
mint,
MintAndAta {
mint_account: mint_account(spl_token::ID, 9),
ata_address: ata,
ata_account: None,
},
)],
);
let intent = TokenAccountIntent {
mints: HashMap::from([(mint, MintIntent::EnsureAtaExists)]),
};
let plan =
prepare_token_accounts(&state, &intent, TokenAccountPlanConfig::with_rent(0)).unwrap();
assert_eq!(plan.create_instructions.len(), 1);
assert_eq!(plan.create_instructions[0].data, vec![1u8]);
}
#[test]
fn mint_intent_require_token_balance_passes_when_sufficient() {
let owner = Pubkey::new_unique();
let mint = Pubkey::new_unique();
let ata = Pubkey::new_unique();
let state = state_with_entries(
owner,
[(
mint,
MintAndAta {
mint_account: mint_account(spl_token::ID, 9),
ata_address: ata,
ata_account: Some(token_account_with_amount(200)),
},
)],
);
let intent = TokenAccountIntent {
mints: HashMap::from([(mint, MintIntent::RequireTokenBalance { amount: 100 })]),
};
let plan =
prepare_token_accounts(&state, &intent, TokenAccountPlanConfig::with_rent(0)).unwrap();
assert!(plan.create_instructions.is_empty());
assert_eq!(plan.token_account_addresses.get(&mint), Some(&ata));
}
#[test]
fn mint_intent_require_token_balance_passes_for_token2022_owned_base_account() {
let owner = Pubkey::new_unique();
let mint = Pubkey::new_unique();
let ata = Pubkey::new_unique();
let state = state_with_entries(
owner,
[(
mint,
MintAndAta {
mint_account: mint_account(spl_token_2022_interface::id(), 9),
ata_address: ata,
ata_account: Some(token_account_with_amount_and_owner(
200,
spl_token_2022_interface::id(),
)),
},
)],
);
let intent = TokenAccountIntent {
mints: HashMap::from([(mint, MintIntent::RequireTokenBalance { amount: 100 })]),
};
let plan =
prepare_token_accounts(&state, &intent, TokenAccountPlanConfig::with_rent(0)).unwrap();
assert!(plan.create_instructions.is_empty());
assert_eq!(plan.token_account_addresses.get(&mint), Some(&ata));
}
#[test]
fn mint_intent_require_token_balance_fails_when_insufficient() {
let owner = Pubkey::new_unique();
let mint = Pubkey::new_unique();
let state = state_with_entries(
owner,
[(
mint,
MintAndAta {
mint_account: mint_account(spl_token::ID, 9),
ata_address: Pubkey::new_unique(),
ata_account: Some(token_account_with_amount(50)),
},
)],
);
let intent = TokenAccountIntent {
mints: HashMap::from([(mint, MintIntent::RequireTokenBalance { amount: 100 })]),
};
let err =
prepare_token_accounts(&state, &intent, TokenAccountPlanConfig::with_rent(0)).unwrap_err();
match err {
TokenError::InsufficientBalance {
mint: m,
required,
actual,
} => {
assert_eq!(m, mint);
assert_eq!(required, 100);
assert_eq!(actual, 50);
}
_ => panic!("expected InsufficientBalance, got {err:?}"),
}
}
#[test]
fn mint_intent_require_token_balance_fails_when_account_missing() {
let owner = Pubkey::new_unique();
let mint = Pubkey::new_unique();
let state = state_with_entries(
owner,
[(
mint,
MintAndAta {
mint_account: mint_account(spl_token::ID, 9),
ata_address: Pubkey::new_unique(),
ata_account: None,
},
)],
);
let intent = TokenAccountIntent {
mints: HashMap::from([(mint, MintIntent::RequireTokenBalance { amount: 100 })]),
};
let err =
prepare_token_accounts(&state, &intent, TokenAccountPlanConfig::with_rent(0)).unwrap_err();
match err {
TokenError::InsufficientBalance {
mint: m,
required,
actual,
} => {
assert_eq!(m, mint);
assert_eq!(required, 100);
assert_eq!(actual, 0);
}
_ => panic!("expected InsufficientBalance, got {err:?}"),
}
}
#[test]
fn mint_intent_require_token_balance_fails_when_account_missing_even_for_zero_amount() {
let owner = Pubkey::new_unique();
let mint = Pubkey::new_unique();
let state = state_with_entries(
owner,
[(
mint,
MintAndAta {
mint_account: mint_account(spl_token::ID, 9),
ata_address: Pubkey::new_unique(),
ata_account: None,
},
)],
);
let intent = TokenAccountIntent {
mints: HashMap::from([(mint, MintIntent::RequireTokenBalance { amount: 0 })]),
};
let err =
prepare_token_accounts(&state, &intent, TokenAccountPlanConfig::with_rent(0)).unwrap_err();
match err {
TokenError::InsufficientBalance {
mint: m,
required,
actual,
} => {
assert_eq!(m, mint);
assert_eq!(required, 0);
assert_eq!(actual, 0);
}
_ => panic!("expected InsufficientBalance, got {err:?}"),
}
}
#[test]
fn mint_intent_require_token_balance_for_native_sol_returns_require_balance_for_sol_not_supported()
{
let owner = Pubkey::new_unique();
let state = state_with_entries(
owner,
[(
native_mint::ID,
MintAndAta {
mint_account: mint_account(spl_token::ID, native_mint::DECIMALS),
ata_address: Pubkey::new_unique(),
ata_account: None,
},
)],
);
let intent = TokenAccountIntent {
mints: HashMap::from([(
native_mint::ID,
MintIntent::RequireTokenBalance { amount: 1 },
)]),
};
let err =
prepare_token_accounts(&state, &intent, TokenAccountPlanConfig::with_rent(0)).unwrap_err();
assert!(matches!(
err,
TokenError::RequireBalanceForSolNotSupported(m) if m == native_mint::ID
));
}
mod token2022_litesvm {
use std::collections::HashMap;
use litesvm::LiteSVM;
use litesvm_token_extensions::CreateMintWithExtensions;
use solana_account::Account as TkAccount;
use solana_keypair::Keypair;
use solana_pubkey::Pubkey;
use solana_signer::Signer;
use solana_token_toolkit::{
detect_transfer_hooks, get_token_mint_metadata, reject_transfer_hook_mints, MintAndAta,
TokenAccountState, TokenError,
};
fn setup() -> (LiteSVM, Keypair) {
let mut svm = LiteSVM::new();
let payer = Keypair::new();
svm.airdrop(&payer.pubkey(), 100_000_000_000).unwrap();
(svm, payer)
}
fn fetch_mint_account(svm: &LiteSVM, mint: Pubkey) -> TkAccount {
let acc = svm.get_account(&mint).expect("mint account missing");
TkAccount {
lamports: acc.lamports,
data: acc.data,
owner: acc.owner,
executable: acc.executable,
rent_epoch: acc.rent_epoch,
}
}
fn state_with_one(owner: Pubkey, mint: Pubkey, account: TkAccount) -> TokenAccountState {
let mut mints = HashMap::new();
mints.insert(
mint,
MintAndAta {
mint_account: account,
ata_address: Pubkey::new_unique(),
ata_account: None,
},
);
TokenAccountState { owner, mints }
}
#[test]
fn token2022_transfer_fee_extension_parses_correctly() {
let (mut svm, payer) = setup();
let mint = CreateMintWithExtensions::new(&mut svm, &payer)
.decimals(6)
.with_transfer_fee(250, 5_000_000)
.send()
.unwrap();
let mint_account = fetch_mint_account(&svm, mint);
let parsed = get_token_mint_metadata(mint, &mint_account, 100).unwrap();
assert_eq!(parsed.mint.decimals, 6);
assert_eq!(parsed.program_id, spl_token_2022_interface::id());
let fee = parsed
.transfer_fee
.expect("TransferFee should be present on Token-2022 mint with extension");
assert_eq!(fee.fee_bps, 250);
assert_eq!(fee.max_fee, 5_000_000);
assert!(parsed.transfer_hook_program_id.is_none());
}
#[test]
fn token_mint_metadata_includes_program_id_and_hook() {
let (mut svm, payer) = setup();
let hook_program = Pubkey::new_unique();
let mint = CreateMintWithExtensions::new(&mut svm, &payer)
.decimals(6)
.with_transfer_hook(hook_program)
.send()
.unwrap();
let mint_account = fetch_mint_account(&svm, mint);
let parsed = get_token_mint_metadata(mint, &mint_account, 100).unwrap();
assert_eq!(parsed.mint.decimals, 6);
assert_eq!(parsed.program_id, spl_token_2022_interface::id());
assert!(parsed.transfer_fee.is_none());
assert_eq!(parsed.transfer_hook_program_id, Some(hook_program));
}
#[test]
fn detect_transfer_hooks_finds_hook_on_real_token2022_mint() {
let (mut svm, payer) = setup();
let hook_program = Pubkey::new_unique();
let mint = CreateMintWithExtensions::new(&mut svm, &payer)
.decimals(6)
.with_transfer_hook(hook_program)
.send()
.unwrap();
let mint_account = fetch_mint_account(&svm, mint);
let state = state_with_one(Pubkey::new_unique(), mint, mint_account);
let hooks = detect_transfer_hooks(&state);
assert_eq!(hooks.len(), 1);
assert_eq!(hooks[&mint].hook_program_id, hook_program);
}
#[test]
fn reject_transfer_hook_mints_returns_lowest_pubkey_deterministically() {
let (mut svm, payer) = setup();
let hook_program = Pubkey::new_unique();
let mint_a = CreateMintWithExtensions::new(&mut svm, &payer)
.decimals(6)
.with_transfer_hook(hook_program)
.send()
.unwrap();
let mint_b = CreateMintWithExtensions::new(&mut svm, &payer)
.decimals(6)
.with_transfer_hook(hook_program)
.send()
.unwrap();
let acc_a = fetch_mint_account(&svm, mint_a);
let acc_b = fetch_mint_account(&svm, mint_b);
let expected_lowest = if mint_a < mint_b { mint_a } else { mint_b };
let mut mints = HashMap::new();
mints.insert(
mint_a,
MintAndAta {
mint_account: acc_a,
ata_address: Pubkey::new_unique(),
ata_account: None,
},
);
mints.insert(
mint_b,
MintAndAta {
mint_account: acc_b,
ata_address: Pubkey::new_unique(),
ata_account: None,
},
);
let state = TokenAccountState {
owner: Pubkey::new_unique(),
mints,
};
for _ in 0..5 {
let err = reject_transfer_hook_mints(&state).unwrap_err();
match err {
TokenError::TransferHookDetected { mint, program } => {
assert_eq!(mint, expected_lowest);
assert_eq!(program, hook_program);
}
_ => panic!("expected TransferHookDetected, got {err:?}"),
}
}
}
}