Skip to content

Commit 6151e75

Browse files
authored
fix(token): bypass cache for payment-critical account reads (#418)
* fix(token): force fresh account reads in payment validation Bypass Redis account cache for destination and mint lookups in TokenUtil::find_payment_in_transaction to avoid stale account state during payment authorization and pricing. * fix(token2022): reject mutable transfer-hook authority in payments Reject Token-2022 payment mints that carry a mutable TransferHook authority so post-sign hook updates cannot alter the execution surface Kora approved. Apply the same guard for destination ATA-creation flows and extend mint test builders to configure TransferHook authority/program fields for coverage. * fix(config): accept validation.token2022 alias * fix(token): scope token2022 payment checks to payment candidates Run Token-2022 extension and transfer-hook authority validation only after destination-owner matching in find_payment_in_transaction. This avoids rejecting unrelated Token-2022 transfers that are not payment instructions while preserving the mutable transfer-hook guard for actual payment paths. * fix(transaction): enforce signer-slot signature indexing Restrict signer position lookup to the first num_required_signatures account keys so unsigned pubkey occurrences cannot be treated as signer slots. Also switch both normal and bundle signing flows to checked signature-slot writes and return InvalidTransaction on slot mismatch instead of panicking. Add coverage for rejecting unsigned fee payer occurrences in bundle signing tests. * fix(validator): enforce disallowed accounts in instruction data Extend parsed system and SPL/Token-2022 instruction data to retain destination authority/owner pubkeys carried in instruction bytes (e.g. AuthorizeNonceAccount new_authority, SetAuthority new_authority, InitializeMint freeze_authority). Add a dedicated disallowed-instruction-data validation pass and run it during transaction validation so blacklisted pubkeys in instruction data are rejected the same way as account metas/program IDs. Includes new validator tests for nonce authorize, SPL/Token-2022 set_authority, initialize_account2 owner, and initialize_mint2 freeze_authority bypass paths. * fix(token): net token2022 inflows by transfer fee Adjust SPL transfer value accounting so Token-2022 inflows to fee-payer-owned accounts are credited at post-fee amounts, while outflows remain gross. This closes the transfer-fee over-credit path where fee payer outflow was underestimated against max_allowed_lamports. * fix(validator): block confidential token2022 and parse reallocate Reject confidential Token-2022 extension instruction families by default and add explicit Token-2022 Reallocate parsing. Also reject fee-payer-involved Token-2022 Reallocate usage in transaction validation to close fee-payer policy bypass paths. --------- Co-authored-by: Jo D <dev-jodee@users.noreply.github.com>
1 parent 887a857 commit 6151e75

File tree

13 files changed

+969
-91
lines changed

13 files changed

+969
-91
lines changed

crates/lib/src/config.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ pub struct ValidationConfig {
126126
pub fee_payer_policy: FeePayerPolicy,
127127
#[serde(default)]
128128
pub price: PriceConfig,
129-
#[serde(default)]
129+
#[serde(default, alias = "token2022")]
130130
pub token_2022: Token2022Config,
131131
/// Allow durable transactions (nonce-based). Default: false.
132132
/// When false, rejects any transaction containing AdvanceNonceAccount instruction.
@@ -834,6 +834,42 @@ mod tests {
834834
assert!(config.validation.token_2022.get_blocked_account_extensions().is_empty());
835835
}
836836

837+
#[test]
838+
fn test_token2022_config_parsing_alias_token2022_table() {
839+
let wrong_key_alias_toml = r#"
840+
[validation]
841+
max_allowed_lamports = 1
842+
max_signatures = 1
843+
allowed_programs = []
844+
allowed_tokens = []
845+
allowed_spl_paid_tokens = []
846+
disallowed_accounts = []
847+
price_source = "Mock"
848+
849+
[validation.token2022]
850+
blocked_mint_extensions = ["interest_bearing_config"]
851+
blocked_account_extensions = ["memo_transfer"]
852+
853+
[kora]
854+
rate_limit = 1
855+
"#;
856+
857+
let config = crate::tests::toml_mock::create_invalid_config(wrong_key_alias_toml)
858+
.expect("Config with [validation.token2022] alias should parse");
859+
860+
assert!(
861+
config
862+
.validation
863+
.token_2022
864+
.is_mint_extension_blocked(ExtensionType::InterestBearingConfig),
865+
"InterestBearingConfig should be blocked via [validation.token2022] alias"
866+
);
867+
assert!(
868+
config.validation.token_2022.is_account_extension_blocked(ExtensionType::MemoTransfer),
869+
"MemoTransfer should be blocked via [validation.token2022] alias"
870+
);
871+
}
872+
837873
#[test]
838874
fn test_token2022_extension_blocking_check() {
839875
let config = ConfigBuilder::new()

crates/lib/src/constant.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,13 @@ pub mod instruction_indexes {
212212
pub const FREEZE_AUTHORITY_INDEX: usize = 2;
213213
}
214214

215+
pub mod spl_token_reallocate {
216+
pub const REQUIRED_NUMBER_OF_ACCOUNTS: usize = 4;
217+
pub const ACCOUNT_INDEX: usize = 0;
218+
pub const PAYER_INDEX: usize = 1;
219+
pub const OWNER_INDEX: usize = 3;
220+
}
221+
215222
// ATA instruction indexes
216223
pub mod ata_instruction_indexes {
217224
pub const ATA_ADDRESS_INDEX: usize = 1;

crates/lib/src/signer/bundle_signer.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,16 @@ impl BundleSigner {
6565
};
6666

6767
let fee_payer_position = resolved.find_signer_position(fee_payer)?;
68-
resolved.transaction.signatures[fee_payer_position] = signature;
68+
let signatures_len = resolved.transaction.signatures.len();
69+
let signature_slot = match resolved.transaction.signatures.get_mut(fee_payer_position) {
70+
Some(slot) => slot,
71+
None => {
72+
return Err(KoraError::InvalidTransaction(format!(
73+
"Signer position {fee_payer_position} is out of bounds for signatures (len={signatures_len})"
74+
)));
75+
}
76+
};
77+
*signature_slot = signature;
6978

7079
Ok(())
7180
}
@@ -91,6 +100,18 @@ mod tests {
91100
VersionedTransactionResolved::from_kora_built_transaction(&versioned).unwrap()
92101
}
93102

103+
fn create_test_resolved_with_unsigned_fee_payer_occurrence(
104+
signer_like_fee_payer: &Pubkey,
105+
) -> VersionedTransactionResolved {
106+
let attacker_fee_payer = Keypair::new();
107+
let instruction = transfer(&attacker_fee_payer.pubkey(), signer_like_fee_payer, 1000);
108+
let message = Message::new(&[instruction], Some(&attacker_fee_payer.pubkey()));
109+
let transaction = Transaction::new_unsigned(message);
110+
let versioned = solana_sdk::transaction::VersionedTransaction::from(transaction);
111+
112+
VersionedTransactionResolved::from_kora_built_transaction(&versioned).unwrap()
113+
}
114+
94115
#[tokio::test]
95116
async fn test_sign_transaction_for_bundle_success() {
96117
let fee_payer_keypair = Keypair::new();
@@ -143,6 +164,31 @@ mod tests {
143164
assert!(matches!(result.unwrap_err(), KoraError::InvalidTransaction(_)));
144165
}
145166

167+
#[tokio::test]
168+
async fn test_sign_transaction_for_bundle_rejects_unsigned_fee_payer_occurrence() {
169+
let fee_payer_keypair = Keypair::new();
170+
let fee_payer = fee_payer_keypair.pubkey();
171+
172+
let external_signer = Signer::from_memory(&fee_payer_keypair.to_base58_string()).unwrap();
173+
let signer = Arc::new(external_signer);
174+
175+
let blockhash = Some(Hash::new_unique());
176+
let config = ConfigMockBuilder::new().build();
177+
178+
let mut resolved = create_test_resolved_with_unsigned_fee_payer_occurrence(&fee_payer);
179+
let result = BundleSigner::sign_transaction_for_bundle(
180+
&mut resolved,
181+
&signer,
182+
&fee_payer,
183+
&blockhash,
184+
&config,
185+
)
186+
.await;
187+
188+
assert!(result.is_err());
189+
assert!(matches!(result.unwrap_err(), KoraError::InvalidTransaction(_)));
190+
}
191+
146192
#[tokio::test]
147193
async fn test_sign_transaction_for_bundle_signature_position() {
148194
let fee_payer_keypair = Keypair::new();

crates/lib/src/tests/account_mock.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,8 @@ pub struct MintAccountMockBuilder {
307307
rent_epoch: u64,
308308
// Token2022-specific fields
309309
extensions: Vec<ExtensionType>,
310+
transfer_hook_authority: Option<Pubkey>,
311+
transfer_hook_program_id: Option<Pubkey>,
310312
}
311313

312314
impl Default for MintAccountMockBuilder {
@@ -326,6 +328,8 @@ impl MintAccountMockBuilder {
326328
lamports: 0,
327329
rent_epoch: DEFAULT_RENT_EPOCH,
328330
extensions: Vec::new(),
331+
transfer_hook_authority: None,
332+
transfer_hook_program_id: None,
329333
}
330334
}
331335

@@ -365,6 +369,16 @@ impl MintAccountMockBuilder {
365369
self
366370
}
367371

372+
pub fn with_transfer_hook_authority(mut self, authority: Option<Pubkey>) -> Self {
373+
self.transfer_hook_authority = authority;
374+
self
375+
}
376+
377+
pub fn with_transfer_hook_program_id(mut self, program_id: Option<Pubkey>) -> Self {
378+
self.transfer_hook_program_id = program_id;
379+
self
380+
}
381+
368382
/// Add an extension type (Token2022 only)
369383
pub fn with_extension(mut self, extension: ExtensionType) -> Self {
370384
if !self.extensions.contains(&extension) {
@@ -472,7 +486,12 @@ impl MintAccountMockBuilder {
472486
)?;
473487
}
474488
ExtensionType::TransferHook => {
475-
state.init_extension::<extension::transfer_hook::TransferHook>(true)?;
489+
let transfer_hook =
490+
state.init_extension::<extension::transfer_hook::TransferHook>(true)?;
491+
transfer_hook.authority =
492+
OptionalNonZeroPubkey::try_from(self.transfer_hook_authority)?;
493+
transfer_hook.program_id =
494+
OptionalNonZeroPubkey::try_from(self.transfer_hook_program_id)?;
476495
}
477496
// Add other extension types as needed
478497
_ => {}

0 commit comments

Comments
 (0)