GHSA-725G-W329-G7QR
Vulnerability from github – Published: 2026-03-12 14:50 – Updated: 2026-03-12 14:50Summary
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
// 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:
// 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_checked for amount = 1000 tokens
- 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)
#[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:
// 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
{
"affected": [
{
"package": {
"ecosystem": "crates.io",
"name": "kora-lib"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.0.5"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [],
"github_reviewed": true,
"github_reviewed_at": "2026-03-12T14:50:43Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nWhen a user pays transaction fees using a Token-2022 token with a `TransferFeeConfig` extension, Kora\u0027s `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\u0027s destination account only receives `amount - transfer_fee`. This means the paymaster consistently credits more value than it actually receives, resulting in systematic financial loss.\n\n## Severity\n\n**High**\n\n## Affected Component\n\n- **File:** `crates/lib/src/token/token.rs`\n- **Function:** `verify_token_payment()`\n- **Lines:** 529\u2013654 (specifically 633\u2013639)\n\n## Root Cause\n\nIn `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.\n\nThe 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.\n\n## Vulnerable Code\n\n```rust\n// crates/lib/src/token/token.rs:529-654\npub async fn verify_token_payment(\n transaction_resolved: \u0026mut VersionedTransactionResolved,\n rpc_client: \u0026RpcClient,\n required_lamports: u64,\n expected_destination_owner: \u0026Pubkey,\n) -\u003e Result\u003cbool, KoraError\u003e {\n let config = get_config()?;\n let mut total_lamport_value = 0u64;\n\n // ...\n\n for instruction in transaction_resolved\n .get_or_parse_spl_instructions()?\n .get(\u0026ParsedSPLInstructionType::SplTokenTransfer)\n .unwrap_or(\u0026vec![])\n {\n if let ParsedSPLInstructionData::SplTokenTransfer {\n source_address,\n destination_address,\n mint,\n amount, // \u003c-- This is the PRE-FEE amount from the instruction\n is_2022,\n ..\n } = instruction\n {\n // ... destination validation ...\n\n // LINE 633-639: Uses raw *amount without deducting transfer fee\n let lamport_value = TokenUtil::calculate_token_value_in_lamports(\n *amount, // \u003c-- BUG: Should be (amount - transfer_fee)\n \u0026token_mint,\n config.validation.price_source.clone(),\n rpc_client,\n )\n .await?;\n\n total_lamport_value = total_lamport_value\n .checked_add(lamport_value)\n .ok_or_else(|| {\n KoraError::ValidationError(\"Payment accumulation overflow\".to_string())\n })?;\n }\n }\n\n Ok(total_lamport_value \u003e= required_lamports)\n}\n```\n\nFor comparison, the transfer fee calculation exists elsewhere in the codebase and is used during fee estimation:\n\n```rust\n// crates/lib/src/token/spl_token_2022.rs:165-198\npub fn calculate_transfer_fee(\n \u0026self,\n amount: u64,\n current_epoch: u64,\n) -\u003e Result\u003cOption\u003cu64\u003e, KoraError\u003e {\n if let Some(fee_config) = self.get_transfer_fee() {\n let transfer_fee = if current_epoch \u003e= u64::from(fee_config.newer_transfer_fee.epoch) {\n \u0026fee_config.newer_transfer_fee\n } else {\n \u0026fee_config.older_transfer_fee\n };\n let basis_points = u16::from(transfer_fee.transfer_fee_basis_points);\n let maximum_fee = u64::from(transfer_fee.maximum_fee);\n let fee_amount = (amount as u128)\n .checked_mul(basis_points as u128)\n .and_then(|product| product.checked_div(10_000))\n // ...\n Ok(Some(std::cmp::min(fee_amount, maximum_fee)))\n } else {\n Ok(None)\n }\n}\n```\n\nThis function exists but is **never called** in `verify_token_payment()`.\n\n## Proof of Concept\n\n### Arithmetic Demonstration\n\nGiven:\n- Token-2022 token with 5% transfer fee (500 basis points), whitelisted in `allowed_spl_paid_tokens`\n- Transaction fee cost: 5000 lamports equivalent\n- Token price: 1 token = 5 lamports\n\n**What should happen:**\n- User needs to pay 5000 lamports worth \u2192 1000 tokens\n- Transfer fee on 1000 tokens at 5% = 50 tokens\n- Paymaster destination receives: 1000 - 50 = 950 tokens (worth 4750 lamports)\n- User should be required to pay MORE to cover the fee\n\n**What actually happens:**\n- User sends `transfer_checked` for `amount = 1000` tokens\n- `verify_token_payment()` calculates: 1000 tokens * 5 lamports/token = 5000 lamports\n- 5000 \u003e= 5000 required \u2192 **payment verified as sufficient**\n- But paymaster only received 950 tokens (worth 4750 lamports)\n- **Paymaster lost 250 lamports on this transaction**\n\n**Over 1000 transactions:** Paymaster loses 250,000 lamports (0.25 SOL)\n\n### Runnable Test (using existing test infrastructure)\n\n```rust\n#[tokio::test]\nasync fn test_token2022_transfer_fee_not_deducted_in_verification() {\n // Setup: Token-2022 mint with 10% transfer fee (1000 bps)\n let transfer_fee_config = create_transfer_fee_config(\n 1000, // 10% basis points\n u64::MAX, // no maximum fee cap\n );\n\n let mint_pubkey = Pubkey::new_unique();\n let mint_account = MintAccountMockBuilder::new()\n .with_decimals(6)\n .with_supply(1_000_000_000_000)\n .with_extension(ExtensionType::TransferFeeConfig)\n .build_token2022();\n\n // User sends transfer_checked for 1,000,000 tokens (1 token at 6 decimals)\n let transfer_amount: u64 = 1_000_000;\n\n // What verify_token_payment credits:\n let credited_amount = transfer_amount; // = 1,000,000\n\n // What the paymaster actually receives (after 10% on-chain fee):\n let actual_received = transfer_amount - (transfer_amount * 1000 / 10000); // = 900,000\n\n // BUG: credited_amount (1,000,000) \u003e actual_received (900,000)\n // Paymaster is credited 11.1% MORE than it actually receives\n assert!(credited_amount \u003e actual_received);\n assert_eq!(credited_amount - actual_received, 100_000); // 100,000 token units lost\n\n // The financial loss per transaction = 10% of the payment amount\n // This is NOT a rounding error \u2014 it is a full percentage-based loss\n}\n```\n\n## Impact\n\n- **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.\n- **Loss Scale:** Proportional to `transfer_fee_basis_points / 10000 * payment_amount` per transaction. For a token with 5% fee and 100 transactions/day at $1 each, that is $5/day or $1,825/year in losses.\n- **Precondition:** Requires a Token-2022 token with `TransferFeeConfig` extension to be whitelisted in `allowed_spl_paid_tokens`. The existing test infrastructure already creates such tokens (`TestAccountSetup::create_usdc_mint_2022()` with 100 bps / 1% fee).\n\n## Recommendation\n\nDeduct the Token-2022 transfer fee before calculating the lamport value of the payment:\n\n```rust\n// In verify_token_payment(), after extracting amount:\nlet effective_amount = if *is_2022 {\n // Fetch the mint to check for TransferFeeConfig\n let mint_account = CacheUtil::get_account(\n rpc_client,\n \u0026token_mint,\n false,\n ).await?;\n let mint_info = Token2022MintInfo::from_account_data(\u0026mint_account.data)?;\n\n if let Ok(Some(fee)) = mint_info.calculate_transfer_fee(\n *amount,\n rpc_client.get_epoch_info().await?.epoch,\n ) {\n amount.saturating_sub(fee)\n } else {\n *amount\n }\n} else {\n *amount\n};\n\nlet lamport_value = TokenUtil::calculate_token_value_in_lamports(\n effective_amount, // Use post-fee amount\n \u0026token_mint,\n config.validation.price_source.clone(),\n rpc_client,\n)\n.await?;\n```\n\n## References\n\n- `crates/lib/src/token/token.rs:529-654` \u2014 `verify_token_payment()` using raw amount\n- `crates/lib/src/token/spl_token_2022.rs:165-198` \u2014 `calculate_transfer_fee()` (exists but not called in verification)\n- `crates/lib/src/fee/fee.rs:174-204` \u2014 `analyze_payment_instructions()` (correctly accounts for transfer fee in estimation)\n- SPL Token-2022 specification: transfer fees are deducted from the transfer amount by the on-chain program",
"id": "GHSA-725g-w329-g7qr",
"modified": "2026-03-12T14:50:43Z",
"published": "2026-03-12T14:50:43Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/solana-foundation/kora/security/advisories/GHSA-725g-w329-g7qr"
},
{
"type": "WEB",
"url": "https://github.com/solana-foundation/kora/commit/8cbd8217ee505e6b37c63ef835ff095cfa8ab318"
},
{
"type": "PACKAGE",
"url": "https://github.com/solana-foundation/kora"
}
],
"schema_version": "1.4.0",
"severity": [],
"summary": "kora-lib: Token-2022 Transfer Fee Not Deducted During Payment Verification"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.