kora_lib/bundle/
helper.rs

1use crate::{
2    bundle::{BundleError, JitoError},
3    config::Config,
4    constant::ESTIMATED_LAMPORTS_FOR_PAYMENT_INSTRUCTION,
5    fee::fee::{FeeConfigUtil, TransactionFeeUtil},
6    signer::bundle_signer::BundleSigner,
7    token::token::TokenUtil,
8    transaction::{TransactionUtil, VersionedTransactionResolved},
9    usage_limit::UsageTracker,
10    validator::transaction_validator::TransactionValidator,
11    KoraError,
12};
13use solana_client::nonblocking::rpc_client::RpcClient;
14use solana_commitment_config::CommitmentConfig;
15use solana_sdk::{instruction::Instruction, pubkey::Pubkey};
16use std::sync::Arc;
17
18pub struct BundleProcessor {
19    pub resolved_transactions: Vec<VersionedTransactionResolved>,
20    pub total_required_lamports: u64,
21    pub total_payment_lamports: u64,
22    pub total_solana_estimated_fee: u64,
23}
24
25impl BundleProcessor {
26    pub async fn process_bundle(
27        encoded_txs: &[String],
28        fee_payer: Pubkey,
29        payment_destination: &Pubkey,
30        config: &Config,
31        rpc_client: &Arc<RpcClient>,
32        sig_verify: bool,
33    ) -> Result<Self, KoraError> {
34        let validator = TransactionValidator::new(config, fee_payer)?;
35        let mut resolved_transactions = Vec::with_capacity(encoded_txs.len());
36        let mut total_required_lamports = 0u64;
37        let mut all_bundle_instructions: Vec<Instruction> = Vec::new();
38        let mut txs_missing_payment_count = 0u64;
39
40        // Phase 1: Decode, resolve, validate, calc fees, collect instructions
41        for encoded in encoded_txs {
42            let transaction = TransactionUtil::decode_b64_transaction(encoded)?;
43            UsageTracker::check_transaction_usage_limit(config, &transaction).await?;
44
45            let mut resolved_tx = VersionedTransactionResolved::from_transaction(
46                &transaction,
47                config,
48                rpc_client,
49                sig_verify,
50            )
51            .await?;
52
53            validator.validate_transaction(config, &mut resolved_tx, rpc_client).await?;
54
55            let fee_calc = FeeConfigUtil::estimate_kora_fee(
56                &mut resolved_tx,
57                &fee_payer,
58                config.validation.is_payment_required(),
59                rpc_client,
60                config,
61            )
62            .await?;
63
64            total_required_lamports =
65                total_required_lamports.checked_add(fee_calc.total_fee_lamports).ok_or_else(
66                    || KoraError::ValidationError("Bundle fee calculation overflow".to_string()),
67                )?;
68
69            // Track how many transactions are missing payment instructions
70            if fee_calc.payment_instruction_fee > 0 {
71                txs_missing_payment_count += 1;
72            }
73
74            all_bundle_instructions.extend(resolved_tx.all_instructions.clone());
75            resolved_transactions.push(resolved_tx);
76        }
77
78        // For bundles, only ONE payment instruction is needed across all transactions.
79        // If multiple transactions are missing payments, we've overcounted by
80        // (txs_missing_payment_count - 1) * ESTIMATED_LAMPORTS_FOR_PAYMENT_INSTRUCTION
81        if txs_missing_payment_count > 1 {
82            let overcount =
83                (txs_missing_payment_count - 1) * ESTIMATED_LAMPORTS_FOR_PAYMENT_INSTRUCTION;
84
85            total_required_lamports =
86                total_required_lamports.checked_sub(overcount).ok_or_else(|| {
87                    KoraError::ValidationError("Bundle fee calculation overflow".to_string())
88                })?;
89        }
90
91        // Phase 2: Calculate payments with cross-tx ATA visibility
92        let mut total_payment_lamports = 0u64;
93        let mut total_solana_estimated_fee = 0u64;
94        for resolved in resolved_transactions.iter_mut() {
95            if let Some(payment) = TokenUtil::find_payment_in_transaction(
96                config,
97                resolved,
98                rpc_client,
99                payment_destination,
100                Some(&all_bundle_instructions),
101            )
102            .await?
103            {
104                total_payment_lamports =
105                    total_payment_lamports.checked_add(payment).ok_or_else(|| {
106                        KoraError::ValidationError("Payment calculation overflow".to_string())
107                    })?;
108            }
109
110            let fee = TransactionFeeUtil::get_estimate_fee_resolved(rpc_client, resolved).await?;
111            total_solana_estimated_fee =
112                total_solana_estimated_fee.checked_add(fee).ok_or_else(|| {
113                    KoraError::ValidationError("Bundle Solana fee calculation overflow".to_string())
114                })?;
115
116            validator.validate_lamport_fee(total_solana_estimated_fee)?;
117        }
118
119        Ok(Self {
120            resolved_transactions,
121            total_required_lamports,
122            total_payment_lamports,
123            total_solana_estimated_fee,
124        })
125    }
126
127    fn validate_payment(&self) -> Result<(), KoraError> {
128        if self.total_payment_lamports < self.total_required_lamports {
129            return Err(BundleError::Jito(JitoError::InsufficientBundlePayment(
130                self.total_required_lamports,
131                self.total_payment_lamports,
132            ))
133            .into());
134        }
135        Ok(())
136    }
137
138    pub async fn sign_all(
139        mut self,
140        signer: &Arc<solana_keychain::Signer>,
141        fee_payer: &Pubkey,
142        rpc_client: &RpcClient,
143    ) -> Result<Vec<VersionedTransactionResolved>, KoraError> {
144        self.validate_payment()?;
145
146        let mut blockhash = None;
147
148        for resolved in self.resolved_transactions.iter_mut() {
149            // Get latest blockhash if signatures are empty and blockhash is not set
150            if blockhash.is_none() && resolved.transaction.signatures.is_empty() {
151                blockhash = Some(
152                    rpc_client
153                        .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
154                        .await?
155                        .0,
156                );
157            }
158
159            BundleSigner::sign_transaction_for_bundle(resolved, signer, fee_payer, &blockhash)
160                .await?;
161        }
162
163        Ok(self.resolved_transactions)
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_validate_payment_sufficient() {
173        let processor = BundleProcessor {
174            resolved_transactions: vec![],
175            total_required_lamports: 1000,
176            total_payment_lamports: 1500,
177            total_solana_estimated_fee: 1000,
178        };
179
180        assert!(processor.validate_payment().is_ok());
181    }
182
183    #[test]
184    fn test_validate_payment_exact() {
185        let processor = BundleProcessor {
186            resolved_transactions: vec![],
187            total_required_lamports: 1000,
188            total_payment_lamports: 1000,
189            total_solana_estimated_fee: 1000,
190        };
191
192        assert!(processor.validate_payment().is_ok());
193    }
194
195    #[test]
196    fn test_validate_payment_insufficient() {
197        let processor = BundleProcessor {
198            resolved_transactions: vec![],
199            total_required_lamports: 2000,
200            total_payment_lamports: 1000,
201            total_solana_estimated_fee: 1000,
202        };
203
204        let result = processor.validate_payment();
205        assert!(result.is_err());
206        let err = result.unwrap_err();
207        assert!(matches!(err, KoraError::JitoError(_)));
208        if let KoraError::JitoError(msg) = err {
209            assert!(msg.contains("insufficient"));
210            assert!(msg.contains("2000"));
211            assert!(msg.contains("1000"));
212        }
213    }
214
215    #[test]
216    fn test_validate_payment_zero_required() {
217        let processor = BundleProcessor {
218            resolved_transactions: vec![],
219            total_required_lamports: 0,
220            total_payment_lamports: 0,
221            total_solana_estimated_fee: 1000,
222        };
223
224        assert!(processor.validate_payment().is_ok());
225    }
226
227    #[test]
228    fn test_validate_payment_max_values() {
229        let processor = BundleProcessor {
230            resolved_transactions: vec![],
231            total_required_lamports: u64::MAX,
232            total_payment_lamports: u64::MAX,
233            total_solana_estimated_fee: 1000,
234        };
235
236        assert!(processor.validate_payment().is_ok());
237    }
238
239    #[test]
240    fn test_validate_payment_one_lamport_short() {
241        let processor = BundleProcessor {
242            resolved_transactions: vec![],
243            total_required_lamports: 1001,
244            total_payment_lamports: 1000,
245            total_solana_estimated_fee: 500,
246        };
247
248        let result = processor.validate_payment();
249        assert!(result.is_err());
250        let err = result.unwrap_err();
251        assert!(matches!(err, KoraError::JitoError(_)));
252    }
253
254    #[test]
255    fn test_bundle_processor_fields() {
256        let processor = BundleProcessor {
257            resolved_transactions: vec![],
258            total_required_lamports: 5000,
259            total_payment_lamports: 6000,
260            total_solana_estimated_fee: 2500,
261        };
262
263        assert_eq!(processor.total_required_lamports, 5000);
264        assert_eq!(processor.total_payment_lamports, 6000);
265        assert_eq!(processor.total_solana_estimated_fee, 2500);
266        assert!(processor.resolved_transactions.is_empty());
267    }
268}