PT-2026-27021 · Crates.Io · Kora-Lib
Published
2026-03-12
·
Updated
2026-03-12
None
No severity ratings or metrics are available. When they are, we'll update the corresponding info on the page.
Summary
When a user pays transaction fees using a Token-2022 token with a
TransferFeeConfig extension, Kora's verify token payment() credits the full raw transfer amount as the payment value. However, the on-chain SPL Token-2022 program withholds a portion of that amount as a transfer fee, so the paymaster's destination account only receives amount - transfer fee. This means the paymaster consistently credits more value than it actually receives, resulting in systematic financial loss.Severity
High
Affected Component
- File:
crates/lib/src/token/token.rs - Function:
verify token payment() - Lines: 529–654 (specifically 633–639)
Root Cause
In
verify token payment(), the amount extracted from the parsed SPL transfer instruction is the pre-fee amount (what the sender specifies in the transfer checked instruction). The function passes this raw amount to calculate token value in lamports() to determine how many lamports the payment is worth. It never subtracts the Token-2022 transfer fee.The fee estimation path (
fee.rs:analyze payment instructions) correctly accounts for transfer fees by calculating them and adding them to the total fee. But the verification path does not perform the inverse subtraction, creating an asymmetry.Vulnerable Code
rust
// crates/lib/src/token/token.rs:529-654
pub async fn verify token payment(
transaction resolved: &mut VersionedTransactionResolved,
rpc client: &RpcClient,
required lamports: u64,
expected destination owner: &Pubkey,
) -> Result<bool, KoraError> {
let config = get config()?;
let mut total lamport value = 0u64;
// ...
for instruction in transaction resolved
.get or parse spl instructions()?
.get(&ParsedSPLInstructionType::SplTokenTransfer)
.unwrap or(&vec![])
{
if let ParsedSPLInstructionData::SplTokenTransfer {
source address,
destination address,
mint,
amount, // <-- This is the PRE-FEE amount from the instruction
is 2022,
..
} = instruction
{
// ... destination validation ...
// LINE 633-639: Uses raw *amount without deducting transfer fee
let lamport value = TokenUtil::calculate token value in lamports(
*amount, // <-- BUG: Should be (amount - transfer fee)
&token mint,
config.validation.price source.clone(),
rpc client,
)
.await?;
total lamport value = total lamport value
.checked add(lamport value)
.ok or else(|| {
KoraError::ValidationError("Payment accumulation overflow".to string())
})?;
}
}
Ok(total lamport value >= required lamports)
}For comparison, the transfer fee calculation exists elsewhere in the codebase and is used during fee estimation:
rust
// crates/lib/src/token/spl token 2022.rs:165-198
pub fn calculate transfer fee(
&self,
amount: u64,
current epoch: u64,
) -> Result<Option<u64>, KoraError> {
if let Some(fee config) = self.get transfer fee() {
let transfer fee = if current epoch >= u64::from(fee config.newer transfer fee.epoch) {
&fee config.newer transfer fee
} else {
&fee config.older transfer fee
};
let basis points = u16::from(transfer fee.transfer fee basis points);
let maximum fee = u64::from(transfer fee.maximum fee);
let fee amount = (amount as u128)
.checked mul(basis points as u128)
.and then(|product| product.checked div(10 000))
// ...
Ok(Some(std::cmp::min(fee amount, maximum fee)))
} else {
Ok(None)
}
}This function exists but is never called in
verify token payment().Proof of Concept
Arithmetic Demonstration
Given:
- Token-2022 token with 5% transfer fee (500 basis points), whitelisted in
allowed spl paid tokens - Transaction fee cost: 5000 lamports equivalent
- Token price: 1 token = 5 lamports
What should happen:
- User needs to pay 5000 lamports worth → 1000 tokens
- Transfer fee on 1000 tokens at 5% = 50 tokens
- Paymaster destination receives: 1000 - 50 = 950 tokens (worth 4750 lamports)
- User should be required to pay MORE to cover the fee
What actually happens:
- User sends
transfer checkedforamount = 1000tokens verify token payment()calculates: 1000 tokens * 5 lamports/token = 5000 lamports- 5000 >= 5000 required → payment verified as sufficient
- But paymaster only received 950 tokens (worth 4750 lamports)
- Paymaster lost 250 lamports on this transaction
Over 1000 transactions: Paymaster loses 250,000 lamports (0.25 SOL)
Runnable Test (using existing test infrastructure)
rust
#[tokio::test]
async fn test token2022 transfer fee not deducted in verification() {
// Setup: Token-2022 mint with 10% transfer fee (1000 bps)
let transfer fee config = create transfer fee config(
1000, // 10% basis points
u64::MAX, // no maximum fee cap
);
let mint pubkey = Pubkey::new unique();
let mint account = MintAccountMockBuilder::new()
.with decimals(6)
.with supply(1 000 000 000 000)
.with extension(ExtensionType::TransferFeeConfig)
.build token2022();
// User sends transfer checked for 1,000,000 tokens (1 token at 6 decimals)
let transfer amount: u64 = 1 000 000;
// What verify token payment credits:
let credited amount = transfer amount; // = 1,000,000
// What the paymaster actually receives (after 10% on-chain fee):
let actual received = transfer amount - (transfer amount * 1000 / 10000); // = 900,000
// BUG: credited amount (1,000,000) > actual received (900,000)
// Paymaster is credited 11.1% MORE than it actually receives
assert!(credited amount > actual received);
assert eq!(credited amount - actual received, 100 000); // 100,000 token units lost
// The financial loss per transaction = 10% of the payment amount
// This is NOT a rounding error — it is a full percentage-based loss
}Impact
- Systematic Financial Loss: The paymaster consistently credits more token value than it receives for every transaction paid with a transfer-fee-bearing Token-2022 token.
- Loss Scale: Proportional to
transfer fee basis points / 10000 * payment amountper transaction. For a token with 5% fee and 100 transactions/day at $1 each, that is $5/day or $1,825/year in losses. - Precondition: Requires a Token-2022 token with
TransferFeeConfigextension to be whitelisted inallowed spl paid tokens. The existing test infrastructure already creates such tokens (TestAccountSetup::create usdc mint 2022()with 100 bps / 1% fee).
Recommendation
Deduct the Token-2022 transfer fee before calculating the lamport value of the payment:
rust
// In verify token payment(), after extracting amount:
let effective amount = if *is 2022 {
// Fetch the mint to check for TransferFeeConfig
let mint account = CacheUtil::get account(
rpc client,
&token mint,
false,
).await?;
let mint info = Token2022MintInfo::from account data(&mint account.data)?;
if let Ok(Some(fee)) = mint info.calculate transfer fee(
*amount,
rpc client.get epoch info().await?.epoch,
) {
amount.saturating sub(fee)
} else {
*amount
}
} else {
*amount
};
let lamport value = TokenUtil::calculate token value in lamports(
effective amount, // Use post-fee amount
&token mint,
config.validation.price source.clone(),
rpc client,
)
.await?;References
crates/lib/src/token/token.rs:529-654—verify token payment()using raw amountcrates/lib/src/token/spl token 2022.rs:165-198—calculate transfer fee()(exists but not called in verification)crates/lib/src/fee/fee.rs:174-204—analyze payment instructions()(correctly accounts for transfer fee in estimation)- SPL Token-2022 specification: transfer fees are deducted from the transfer amount by the on-chain program
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Kora-Lib