I looked into this a bit myself a while ago, definitely not an expert but this is what I figured out: I think it's basically that the _checked
variant requires you to pass the mint account and the number of decimals the token has, and if you pass the wrong mint account or decimals number then it will error. If it errors then the transaction is rolled back safely. This is presumably intended so that you don't accidentally eg. send someone 1,000,000 tokens when you meant to send them 10 because you thought it had 6 decimals but it actually has 1. Or send the wrong token.
The amount you send is always 10^decimals
. So if you have the number of decimals wrong you can send a very different number to what you intended.
The distinction actually carries all the way through to the core Rust code that processes the instruction, so they're separate instructions in the program code. The relevant source code is this file: https://github.com/solana-labs/solana-program-library/blob/master/token/program/src/processor.rs
Here's what it does when it processes a Transfer
instruction:
TokenInstruction::Transfer { amount } => {
msg!("Instruction: Transfer");
Self::process_transfer(program_id, accounts, amount, None)
}
And when it processes a TransferChecked
one:
TokenInstruction::TransferChecked { amount, decimals } => {
msg!("Instruction: TransferChecked");
Self::process_transfer(program_id, accounts, amount, Some(decimals))
}
So they're both calling process_transfer
, the checked one passes in a Some(decimals)
as the last argument while the unchecked passes None
.
Here's process_transfer
:
pub fn process_transfer(
program_id: &Pubkey,
accounts: &[AccountInfo],
amount: u64,
expected_decimals: Option<u8>,
) -> ProgramResult
That last argument is expected_decimals
, an option. So that matches.
Here's some of what that function does:
let expected_mint_info = if let Some(expected_decimals) = expected_decimals {
Some((next_account_info(account_info_iter)?, expected_decimals))
} else {
None
};
If expected_decimals
is defined (as in the checked case, but not the unchecked one), it reads the mint account from the accounts
list and the expected decimals number. If not then expected_mint_info
is None. No mint account or expected decimals is recorded.
Later on:
if let Some((mint_info, expected_decimals)) = expected_mint_info {
if !Self::cmp_pubkeys(mint_info.key, &source_account.mint) {
return Err(TokenError::MintMismatch.into());
}
let mint = Mint::unpack(&mint_info.data.borrow_mut())?;
if expected_decimals != mint.decimals {
return Err(TokenError::MintDecimalsMismatch.into());
}
}
This is where it's doing the checks the documentation referred to. It only does these if expected_mint_info
is defined, which only happens for the checked call. Here it's checking that the passed mint
account has the expected address, and that its number of decimals is the one we passed in. If they don't match the transaction errors.
If you check the source code you'll see it basically works the same for all these checked/unchecked variants. They all have an optional expected_decimals
that triggers them to read a mint account and perform the same checks.
In terms of why you'd use one over the other, the only thing I can think of is that using the unchecked is a performance optimisation. It requires you to fetch and pass less data from the client, and it requires the instruction to do less work.
Also FWIW it looks like Transfer
has been deprecated in program-2022
, which is the upcoming new version of the token program: https://github.com/solana-labs/solana-program-library/blob/master/token/program-2022/src/processor.rs#L1212
#[allow(deprecated)]
TokenInstruction::Transfer { amount } => {
msg!("Instruction: Transfer");
Self::process_transfer(program_id, accounts, amount, None, None)
}