Skip to content
35 changes: 34 additions & 1 deletion crates/lib/src/bundle/helper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::{
lighthouse::LighthouseUtil,
plugin::{PluginExecutionContext, TransactionPluginRunner},
signer::bundle_signer::BundleSigner,
token::token::TokenUtil,
token::token::{TokenUtil, TransferHookValidationFlow},
transaction::{TransactionUtil, VersionedTransactionResolved},
usage_limit::UsageTracker,
validator::transaction_validator::TransactionValidator,
Expand All @@ -29,6 +29,15 @@ pub enum BundleProcessingMode<'a> {
SkipUsage,
}

fn transfer_hook_validation_flow_for_bundle(
plugin_context: Option<PluginExecutionContext>,
) -> TransferHookValidationFlow {
match plugin_context {
Some(PluginExecutionContext::SignBundle) => TransferHookValidationFlow::DelayedSigning,
_ => TransferHookValidationFlow::ImmediateSignAndSend,
}
}

impl BundleProcessor {
/// Extract transactions at specified indices for processing.
/// Returns (filtered_transactions, index_to_position_map).
Expand Down Expand Up @@ -92,6 +101,8 @@ impl BundleProcessor {
) -> Result<Self, KoraError> {
let validator = TransactionValidator::new(config, fee_payer)?;
let plugin_runner = TransactionPluginRunner::from_config(config);
let transfer_hook_validation_flow =
transfer_hook_validation_flow_for_bundle(plugin_context);
let mut resolved_transactions = Vec::with_capacity(encoded_txs.len());
let mut total_required_lamports = 0u64;
let mut all_bundle_instructions: Vec<Instruction> = Vec::new();
Expand Down Expand Up @@ -134,6 +145,7 @@ impl BundleProcessor {
config.validation.is_payment_required(),
rpc_client,
config,
transfer_hook_validation_flow,
)
.await?;

Expand Down Expand Up @@ -262,6 +274,27 @@ impl BundleProcessor {
mod tests {
use super::*;

#[test]
fn test_transfer_hook_validation_flow_for_bundle_sign_bundle() {
let flow =
transfer_hook_validation_flow_for_bundle(Some(PluginExecutionContext::SignBundle));
assert_eq!(flow, TransferHookValidationFlow::DelayedSigning);
}

#[test]
fn test_transfer_hook_validation_flow_for_bundle_sign_and_send_bundle() {
let flow = transfer_hook_validation_flow_for_bundle(Some(
PluginExecutionContext::SignAndSendBundle,
));
assert_eq!(flow, TransferHookValidationFlow::ImmediateSignAndSend);
}

#[test]
fn test_transfer_hook_validation_flow_for_bundle_estimation_context() {
let flow = transfer_hook_validation_flow_for_bundle(None);
assert_eq!(flow, TransferHookValidationFlow::ImmediateSignAndSend);
}

#[test]
fn test_validate_payment_sufficient() {
let processor = BundleProcessor {
Expand Down
32 changes: 32 additions & 0 deletions crates/lib/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,15 +267,32 @@ pub struct AltInstructionPolicy {
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
#[serde(default)]
pub struct Token2022Config {
pub blocked_mint_extensions: Vec<String>,
pub blocked_account_extensions: Vec<String>,
#[serde(default)]
pub transfer_hook_policy: TransferHookPolicy,
#[serde(skip)]
parsed_blocked_mint_extensions: Option<Vec<ExtensionType>>,
#[serde(skip)]
parsed_blocked_account_extensions: Option<Vec<ExtensionType>>,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum TransferHookPolicy {
/// Reject mutable TransferHook authority on all signing flows.
#[default]
DenyAll,
/// Reject mutable TransferHook authority only on delayed-signing flows
/// (signTransaction/signBundle). Allow on immediate sign-and-send flows.
DenyMutableForDelayedSigning,
/// Allow mutable TransferHook authority on all flows.
AllowAll,
}

impl Token2022Config {
/// Initialize and parse extension strings into ExtensionTypes
/// This should be called after deserialization to populate the cached fields
Expand Down Expand Up @@ -855,11 +872,26 @@ mod tests {

assert!(config.validation.token_2022.blocked_mint_extensions.is_empty());
assert!(config.validation.token_2022.blocked_account_extensions.is_empty());
assert_eq!(config.validation.token_2022.transfer_hook_policy, TransferHookPolicy::DenyAll);

assert!(config.validation.token_2022.get_blocked_mint_extensions().is_empty());
assert!(config.validation.token_2022.get_blocked_account_extensions().is_empty());
}

#[test]
fn test_token2022_transfer_hook_policy_parsing() {
let config = ConfigBuilder::new()
.with_custom_section(
r#"[validation.token_2022]
transfer_hook_policy = "allow_all"
"#,
)
.build_config()
.unwrap();

assert_eq!(config.validation.token_2022.transfer_hook_policy, TransferHookPolicy::AllowAll);
}

#[test]
fn test_token2022_config_parsing_alias_token2022_table() {
let wrong_key_alias_toml = r#"
Expand Down
191 changes: 189 additions & 2 deletions crates/lib/src/fee/fee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::{
fee::price::PriceModel,
token::{
spl_token_2022::Token2022Mint,
token::{TokenType, TokenUtil},
token::{TokenType, TokenUtil, TransferHookValidationFlow},
TokenState,
},
transaction::{
Expand Down Expand Up @@ -281,7 +281,18 @@ impl FeeConfigUtil {
is_payment_required: bool,
rpc_client: &RpcClient,
config: &Config,
transfer_hook_validation_flow: TransferHookValidationFlow,
) -> Result<TotalFeeCalculation, KoraError> {
// Always validate Token2022 transfer-hook mutability before pricing logic so
// both free and paid modes enforce the same transfer-hook security guard.
TokenUtil::validate_token2022_transfer_hooks_in_transaction(
config,
transaction,
rpc_client,
transfer_hook_validation_flow,
)
.await?;

match &config.validation.price.model {
PriceModel::Free => Ok(TotalFeeCalculation::new_fixed(0)),
PriceModel::Fixed { strict, .. } => {
Expand Down Expand Up @@ -507,9 +518,14 @@ impl TransactionFeeUtil {
mod tests {
use super::*;
use crate::{
config::TransferHookPolicy,
constant::{ESTIMATED_LAMPORTS_FOR_PAYMENT_INSTRUCTION, LAMPORTS_PER_SIGNATURE},
fee::fee::{FeeConfigUtil, TransactionFeeUtil},
fee::{
fee::{FeeConfigUtil, TransactionFeeUtil},
price::{PriceConfig, PriceModel},
},
tests::{
account_mock::MintAccountMockBuilder,
common::{
create_mock_rpc_client_with_account, create_mock_token_account,
setup_or_get_test_config, setup_or_get_test_signer,
Expand Down Expand Up @@ -537,6 +553,177 @@ mod tests {
};
use spl_associated_token_account_interface::address::get_associated_token_address;

fn create_token2022_transfer_checked_resolved_transaction(
owner: &Pubkey,
source: &Pubkey,
destination: &Pubkey,
mint: &Pubkey,
) -> crate::transaction::VersionedTransactionResolved {
let transfer_ix = spl_token_2022_interface::instruction::transfer_checked(
&spl_token_2022_interface::id(),
source,
mint,
destination,
owner,
&[],
1,
6,
)
.unwrap();

let message = VersionedMessage::Legacy(Message::new(&[transfer_ix], Some(owner)));
TransactionUtil::new_unsigned_versioned_transaction_resolved(message)
.expect("failed to build resolved transaction")
}

async fn estimate_free_fee_with_mutable_transfer_hook(
transfer_hook_policy: TransferHookPolicy,
transfer_hook_validation_flow: TransferHookValidationFlow,
) -> Result<TotalFeeCalculation, KoraError> {
let mut config = ConfigMockBuilder::new().with_cache_enabled(false).build();
config.validation.price = PriceConfig { model: PriceModel::Free };
config.validation.token_2022.transfer_hook_policy = transfer_hook_policy;
let _lock =
ConfigMockBuilder::new().with_validation(config.validation.clone()).build_and_setup();

let owner = Pubkey::new_unique();
let source = Pubkey::new_unique();
let destination = Pubkey::new_unique();
let mint = Pubkey::new_unique();

let mut resolved_tx = create_token2022_transfer_checked_resolved_transaction(
&owner,
&source,
&destination,
&mint,
);

let mint_account = MintAccountMockBuilder::new()
.with_decimals(6)
.with_extension(spl_token_2022_interface::extension::ExtensionType::TransferHook)
.with_transfer_hook_authority(Some(Pubkey::new_unique()))
.with_transfer_hook_program_id(Some(Pubkey::new_unique()))
.build_token2022();
let rpc_client = RpcMockBuilder::new().build_with_sequential_accounts(vec![&mint_account]);

let config = get_config().unwrap();
FeeConfigUtil::estimate_kora_fee(
&mut resolved_tx,
&owner,
config.validation.is_payment_required(),
&rpc_client,
&config,
transfer_hook_validation_flow,
)
.await
}

#[tokio::test]
async fn test_estimate_kora_fee_free_rejects_mutable_transfer_hook_authority() {
let result = estimate_free_fee_with_mutable_transfer_hook(
TransferHookPolicy::DenyMutableForDelayedSigning,
TransferHookValidationFlow::DelayedSigning,
)
.await;

assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("Mutable transfer-hook authority found on mint account"));
}

#[tokio::test]
async fn test_estimate_kora_fee_free_allows_mutable_transfer_hook_authority_for_immediate_sign_and_send(
) {
let result = estimate_free_fee_with_mutable_transfer_hook(
TransferHookPolicy::DenyMutableForDelayedSigning,
TransferHookValidationFlow::ImmediateSignAndSend,
)
.await;

assert!(result.is_ok());
let calculation = result.unwrap();
assert_eq!(calculation.total_fee_lamports, 0);
}

#[tokio::test]
async fn test_estimate_kora_fee_free_rejects_mutable_transfer_hook_authority_when_policy_is_deny_all(
) {
let result = estimate_free_fee_with_mutable_transfer_hook(
TransferHookPolicy::DenyAll,
TransferHookValidationFlow::ImmediateSignAndSend,
)
.await;

assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("Mutable transfer-hook authority found on mint account"));
}

#[tokio::test]
async fn test_estimate_kora_fee_free_allows_mutable_transfer_hook_authority_when_policy_is_allow_all(
) {
let result = estimate_free_fee_with_mutable_transfer_hook(
TransferHookPolicy::AllowAll,
TransferHookValidationFlow::DelayedSigning,
)
.await;

assert!(result.is_ok());
let calculation = result.unwrap();
assert_eq!(calculation.total_fee_lamports, 0);
}

#[tokio::test]
async fn test_estimate_kora_fee_free_allows_immutable_transfer_hook_authority() {
let mut config = ConfigMockBuilder::new().with_cache_enabled(false).build();
config.validation.price = PriceConfig { model: PriceModel::Free };
let _lock =
ConfigMockBuilder::new().with_validation(config.validation.clone()).build_and_setup();

let owner = Pubkey::new_unique();
let source = Pubkey::new_unique();
let destination = Pubkey::new_unique();
let mint = Pubkey::new_unique();

let transfer_ix = spl_token_2022_interface::instruction::transfer_checked(
&spl_token_2022_interface::id(),
&source,
&mint,
&destination,
&owner,
&[],
1,
6,
)
.unwrap();
let message = VersionedMessage::Legacy(Message::new(&[transfer_ix], Some(&owner)));
let mut resolved_tx = TransactionUtil::new_unsigned_versioned_transaction_resolved(message)
.expect("failed to build resolved transaction");

let mint_account = MintAccountMockBuilder::new()
.with_decimals(6)
.with_extension(spl_token_2022_interface::extension::ExtensionType::TransferHook)
.with_transfer_hook_authority(None)
.with_transfer_hook_program_id(Some(Pubkey::new_unique()))
.build_token2022();
let rpc_client = RpcMockBuilder::new().build_with_sequential_accounts(vec![&mint_account]);

let config = get_config().unwrap();
let result = FeeConfigUtil::estimate_kora_fee(
&mut resolved_tx,
&owner,
config.validation.is_payment_required(),
&rpc_client,
&config,
TransferHookValidationFlow::DelayedSigning,
)
.await;

assert!(result.is_ok());
let calculation = result.unwrap();
assert_eq!(calculation.total_fee_lamports, 0);
}

#[test]
fn test_is_fee_payer_in_signers_legacy_fee_payer_is_signer() {
let fee_payer = setup_or_get_test_signer();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::{
fee::fee::FeeConfigUtil,
rpc_server::middleware_utils::default_sig_verify,
state::get_request_signer_with_signer_key,
token::token::TransferHookValidationFlow,
transaction::{TransactionUtil, VersionedTransactionResolved},
};

Expand Down Expand Up @@ -80,6 +81,7 @@ pub async fn estimate_transaction_fee(
validation_config.is_payment_required(),
rpc_client,
config,
TransferHookValidationFlow::ImmediateSignAndSend,
)
.await?;

Expand Down
5 changes: 5 additions & 0 deletions crates/lib/src/tests/toml_mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,11 @@ impl ConfigBuilder {
self
}

pub fn with_custom_section(mut self, section: &str) -> Self {
self.custom_sections.push(section.to_string());
self
}

pub fn build_toml(&self) -> String {
let programs_list = self
.validation
Expand Down
Loading
Loading