Skip to main content

solana_primitives/builder/
transaction.rs

1use crate::{
2    AccountMeta, AddressLookupTableAccount, CompiledInstruction, Instruction, Message,
3    MessageAddressTableLookup, MessageHeader, Pubkey, Result, SignatureBytes, SolanaError,
4    Transaction, VersionedMessageV0, VersionedTransaction,
5};
6use std::collections::{HashMap, HashSet};
7
8/// A builder for constructing Solana transactions
9#[derive(Debug)]
10pub struct TransactionBuilder {
11    /// The fee payer for the transaction
12    fee_payer: Pubkey,
13    /// The instructions to include in the transaction
14    instructions: Vec<Instruction>,
15    /// The recent blockhash
16    recent_blockhash: [u8; 32],
17    /// A map of account public keys to their metadata, including the fee payer
18    account_metas: HashMap<Pubkey, AccountMeta>,
19}
20
21impl TransactionBuilder {
22    /// Create a new transaction builder
23    pub fn new(fee_payer: Pubkey, recent_blockhash: [u8; 32]) -> Self {
24        let mut account_metas = HashMap::new();
25        account_metas.insert(
26            fee_payer,
27            AccountMeta {
28                pubkey: fee_payer,
29                is_signer: true,
30                is_writable: true,
31            },
32        );
33
34        Self {
35            fee_payer, // Store the fee_payer
36            instructions: Vec::new(),
37            recent_blockhash,
38            account_metas,
39        }
40    }
41
42    /// Add an instruction to the transaction
43    pub fn add_instruction(&mut self, instruction: Instruction) -> &mut Self {
44        // Add program ID to account metas. Program IDs are typically not signers and are read-only (executable).
45        self.account_metas
46            .entry(instruction.program_id)
47            .or_insert_with(|| AccountMeta {
48                pubkey: instruction.program_id,
49                is_signer: false,
50                is_writable: false,
51            });
52
53        // Add all accounts from the instruction to our account_metas, merging properties.
54        // If an account is used in multiple instructions, its signer/writable status is the OR of all uses.
55        for account_meta in &instruction.accounts {
56            self.account_metas
57                .entry(account_meta.pubkey)
58                .and_modify(|existing_meta| {
59                    existing_meta.is_signer = existing_meta.is_signer || account_meta.is_signer;
60                    existing_meta.is_writable =
61                        existing_meta.is_writable || account_meta.is_writable;
62                })
63                .or_insert_with(|| account_meta.clone());
64        }
65        self.instructions.push(instruction);
66        self
67    }
68
69    /// Add multiple instructions to the transaction.
70    pub fn add_instructions<I>(&mut self, instructions: I) -> &mut Self
71    where
72        I: IntoIterator<Item = Instruction>,
73    {
74        for instruction in instructions {
75            self.add_instruction(instruction);
76        }
77        self
78    }
79
80    /// Build the transaction
81    pub fn build(self) -> Result<Transaction> {
82        let mut final_account_keys = Vec::new();
83        // HashSet to track keys already added to final_account_keys to prevent duplicates,
84        // though the categorization should handle distinct roles.
85        let mut processed_keys = std::collections::HashSet::new();
86
87        // 1. Fee payer first
88        final_account_keys.push(self.fee_payer);
89        processed_keys.insert(self.fee_payer);
90
91        let mut writable_signers = Vec::new();
92        let mut readonly_signers = Vec::new();
93        let mut writable_non_signers = Vec::new();
94        let mut readonly_non_signers = Vec::new();
95
96        // Categorize all other accounts from account_metas
97        for (pubkey, meta) in &self.account_metas {
98            if *pubkey == self.fee_payer {
99                // Already added
100                continue;
101            }
102            if meta.is_signer {
103                if meta.is_writable {
104                    writable_signers.push(*pubkey);
105                } else {
106                    readonly_signers.push(*pubkey);
107                }
108            } else if meta.is_writable {
109                writable_non_signers.push(*pubkey);
110            } else {
111                readonly_non_signers.push(*pubkey);
112            }
113        }
114
115        // Sort within categories for deterministic output
116        writable_signers.sort();
117        readonly_signers.sort();
118        writable_non_signers.sort();
119        readonly_non_signers.sort();
120
121        // Append categorized keys to final_account_keys, ensuring no duplicates from previous categories
122        for key in writable_signers {
123            if processed_keys.insert(key) {
124                // insert returns true if value was newly inserted
125                final_account_keys.push(key);
126            }
127        }
128        for key in readonly_signers {
129            if processed_keys.insert(key) {
130                final_account_keys.push(key);
131            }
132        }
133        for key in writable_non_signers {
134            if processed_keys.insert(key) {
135                final_account_keys.push(key);
136            }
137        }
138        for key in readonly_non_signers {
139            if processed_keys.insert(key) {
140                final_account_keys.push(key);
141            }
142        }
143
144        let account_keys: Vec<Pubkey> = final_account_keys;
145
146        // Legacy messages address accounts with a single `u8` index (max 256 accounts).
147        if account_keys.len() > u8::MAX as usize + 1 {
148            return Err(SolanaError::InvalidMessage);
149        }
150
151        // Create a map of pubkey to index for quick lookups
152        let key_to_index: HashMap<Pubkey, u8> = account_keys
153            .iter()
154            .enumerate()
155            .map(|(i, &key)| (key, i as u8))
156            .collect();
157
158        // Compile instructions
159        let compiled_instructions: Vec<CompiledInstruction> = self
160            .instructions
161            .iter()
162            .map(|instruction| {
163                let program_id_index = key_to_index[&instruction.program_id];
164                let accounts: Vec<u8> = instruction
165                    .accounts
166                    .iter()
167                    .map(|meta| key_to_index[&meta.pubkey])
168                    .collect();
169
170                CompiledInstruction {
171                    program_id_index,
172                    accounts,
173                    data: instruction.data.clone(),
174                }
175            })
176            .collect();
177
178        // Each count below can independently reach 256 and wrap when cast to u8.
179        let num_required_signatures = self
180            .account_metas
181            .values()
182            .filter(|meta| meta.is_signer)
183            .count();
184
185        let num_readonly_signed_accounts = self
186            .account_metas
187            .values()
188            .filter(|meta| meta.is_signer && !meta.is_writable)
189            .count();
190
191        let num_readonly_unsigned_accounts = self
192            .account_metas
193            .values()
194            .filter(|meta| !meta.is_signer && !meta.is_writable)
195            .count();
196
197        if num_required_signatures > u8::MAX as usize
198            || num_readonly_signed_accounts > u8::MAX as usize
199            || num_readonly_unsigned_accounts > u8::MAX as usize
200        {
201            return Err(SolanaError::InvalidMessage);
202        }
203
204        let header = MessageHeader {
205            num_required_signatures: num_required_signatures as u8,
206            num_readonly_signed_accounts: num_readonly_signed_accounts as u8,
207            num_readonly_unsigned_accounts: num_readonly_unsigned_accounts as u8,
208        };
209
210        // Create message
211        let message = Message {
212            header,
213            account_keys,
214            recent_blockhash: self.recent_blockhash,
215            instructions: compiled_instructions,
216        };
217
218        // Create empty signatures vector
219        let signatures = vec![SignatureBytes::new([0u8; 64]); num_required_signatures];
220
221        Ok(Transaction {
222            signatures,
223            message,
224        })
225    }
226
227    /// Build a V0 versioned transaction.
228    pub fn build_v0(
229        self,
230        address_lookup_tables: &[AddressLookupTableAccount],
231    ) -> Result<VersionedTransaction> {
232        let mut lookup_map: HashMap<Pubkey, (usize, u8)> = HashMap::new();
233        for (table_index, table) in address_lookup_tables.iter().enumerate().rev() {
234            for (entry_index, address) in table.addresses.iter().enumerate() {
235                if let Ok(entry_index_u8) = u8::try_from(entry_index) {
236                    lookup_map.insert(*address, (table_index, entry_index_u8));
237                } else {
238                    break;
239                }
240            }
241        }
242
243        let program_ids: HashSet<Pubkey> = self
244            .instructions
245            .iter()
246            .map(|instruction| instruction.program_id)
247            .collect();
248
249        let mut flags: HashMap<Pubkey, (bool, bool)> = HashMap::new();
250        let mut order: Vec<Pubkey> = Vec::new();
251        let mut merge = |pubkey: Pubkey, is_signer: bool, is_writable: bool| {
252            flags
253                .entry(pubkey)
254                .and_modify(|(existing_signer, existing_writable)| {
255                    *existing_signer |= is_signer;
256                    *existing_writable |= is_writable;
257                })
258                .or_insert_with(|| {
259                    order.push(pubkey);
260                    (is_signer, is_writable)
261                });
262        };
263
264        merge(self.fee_payer, true, true);
265        for instruction in &self.instructions {
266            merge(instruction.program_id, false, false);
267            for account_meta in &instruction.accounts {
268                merge(
269                    account_meta.pubkey,
270                    account_meta.is_signer,
271                    account_meta.is_writable,
272                );
273            }
274        }
275
276        let mut static_keys: [Vec<Pubkey>; 4] = Default::default();
277        let mut lookup_writable: Vec<Vec<(Pubkey, u8)>> =
278            vec![Vec::new(); address_lookup_tables.len()];
279        let mut lookup_readonly: Vec<Vec<(Pubkey, u8)>> =
280            vec![Vec::new(); address_lookup_tables.len()];
281
282        for pubkey in &order {
283            let (is_signer, is_writable) = flags
284                .get(pubkey)
285                .copied()
286                .ok_or(SolanaError::InvalidMessage)?;
287
288            if is_signer || program_ids.contains(pubkey) || !lookup_map.contains_key(pubkey) {
289                let bucket = match (is_signer, is_writable) {
290                    (true, true) => 0,
291                    (true, false) => 1,
292                    (false, true) => 2,
293                    (false, false) => 3,
294                };
295                static_keys[bucket].push(*pubkey);
296            } else {
297                let (table_index, entry_index) = lookup_map
298                    .get(pubkey)
299                    .copied()
300                    .ok_or(SolanaError::InvalidMessage)?;
301                if is_writable {
302                    lookup_writable[table_index].push((*pubkey, entry_index));
303                } else {
304                    lookup_readonly[table_index].push((*pubkey, entry_index));
305                }
306            }
307        }
308
309        let mut account_keys = Vec::with_capacity(static_keys.iter().map(Vec::len).sum());
310        account_keys.push(self.fee_payer);
311
312        account_keys.extend(
313            static_keys[0]
314                .iter()
315                .copied()
316                .filter(|pubkey| *pubkey != self.fee_payer),
317        );
318
319        for bucket in &static_keys[1..] {
320            account_keys.extend(bucket.iter().copied());
321        }
322
323        if account_keys.len() > u8::MAX as usize {
324            return Err(SolanaError::InvalidMessage);
325        }
326
327        let header = MessageHeader {
328            num_required_signatures: (static_keys[0].len() + static_keys[1].len()) as u8,
329            num_readonly_signed_accounts: static_keys[1].len() as u8,
330            num_readonly_unsigned_accounts: static_keys[3].len() as u8,
331        };
332
333        let mut virtual_index_map: HashMap<Pubkey, u8> = HashMap::new();
334        for (next_virtual_index, (pubkey, _)) in (account_keys.len()..).zip(
335            lookup_writable
336                .iter()
337                .flat_map(|entries| entries.iter())
338                .chain(lookup_readonly.iter().flat_map(|entries| entries.iter())),
339        ) {
340            let virtual_index =
341                u8::try_from(next_virtual_index).map_err(|_| SolanaError::InvalidMessage)?;
342            virtual_index_map.insert(*pubkey, virtual_index);
343        }
344
345        let address_table_lookups: Vec<MessageAddressTableLookup> = address_lookup_tables
346            .iter()
347            .enumerate()
348            .filter_map(|(table_index, table)| {
349                let writable_indexes: Vec<u8> = lookup_writable[table_index]
350                    .iter()
351                    .map(|(_, entry_index)| *entry_index)
352                    .collect();
353                let readonly_indexes: Vec<u8> = lookup_readonly[table_index]
354                    .iter()
355                    .map(|(_, entry_index)| *entry_index)
356                    .collect();
357
358                if writable_indexes.is_empty() && readonly_indexes.is_empty() {
359                    return None;
360                }
361
362                Some(MessageAddressTableLookup::new(
363                    table.key,
364                    writable_indexes,
365                    readonly_indexes,
366                ))
367            })
368            .collect();
369
370        let static_index_map: HashMap<Pubkey, u8> = account_keys
371            .iter()
372            .enumerate()
373            .map(|(index, pubkey)| (*pubkey, index as u8))
374            .collect();
375
376        let compiled_instructions: Vec<CompiledInstruction> = self
377            .instructions
378            .iter()
379            .map(|instruction| {
380                let program_id_index = static_index_map
381                    .get(&instruction.program_id)
382                    .copied()
383                    .ok_or(SolanaError::InvalidMessage)?;
384
385                let accounts = instruction
386                    .accounts
387                    .iter()
388                    .map(|account_meta| {
389                        static_index_map
390                            .get(&account_meta.pubkey)
391                            .copied()
392                            .or_else(|| virtual_index_map.get(&account_meta.pubkey).copied())
393                            .ok_or(SolanaError::InvalidMessage)
394                    })
395                    .collect::<Result<Vec<_>>>()?;
396
397                Ok(CompiledInstruction {
398                    program_id_index,
399                    accounts,
400                    data: instruction.data.clone(),
401                })
402            })
403            .collect::<Result<Vec<_>>>()?;
404
405        let signatures = vec![SignatureBytes::default(); header.num_required_signatures as usize];
406
407        Ok(VersionedTransaction::V0 {
408            signatures,
409            message: VersionedMessageV0 {
410                header,
411                account_keys,
412                recent_blockhash: self.recent_blockhash,
413                instructions: compiled_instructions,
414                address_table_lookups,
415            },
416        })
417    }
418
419    /// One-shot helper for compiling a V0 transaction.
420    pub fn build_v0_transaction(
421        fee_payer: Pubkey,
422        recent_blockhash: [u8; 32],
423        instructions: &[Instruction],
424        address_lookup_tables: &[AddressLookupTableAccount],
425    ) -> Result<VersionedTransaction> {
426        let mut builder = TransactionBuilder::new(fee_payer, recent_blockhash);
427        builder.add_instructions(instructions.iter().cloned());
428        builder.build_v0(address_lookup_tables)
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::TransactionBuilder;
435    use crate::Pubkey;
436    use crate::SolanaError;
437    use crate::builder::InstructionBuilder;
438    use crate::instructions::{
439        program_ids::{system_program, token_program},
440        system::{create_account, transfer},
441        token::transfer_checked,
442    };
443    use crate::types::instruction::AccountMeta;
444    use crate::types::{
445        AddressLookupTableAccount, Instruction, SignatureBytes, VersionedTransaction,
446    };
447    use base64::Engine;
448    use base64::engine::general_purpose::STANDARD;
449
450    fn mint_pubkey() -> Pubkey {
451        Pubkey::from_base58("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v").unwrap()
452    }
453
454    fn token_pubkey() -> Pubkey {
455        Pubkey::from_base58("4q2wPZuZwQTB1dEU9sMGsJK1d8NSL1hpBjTGHBsLQNDh").unwrap()
456    }
457
458    fn authority_pubkey() -> Pubkey {
459        Pubkey::from_base58("Hozo7TadHq6PMMiGLGNvgk79Hvj5VTAM7Ny2bamQ2m8q").unwrap()
460    }
461
462    fn payer_pubkey() -> Pubkey {
463        Pubkey::from_base58("7o36UsWR1JQLpZ9PE2gn9L4SQ69CNNiWAXd4Jt7rqz9Z").unwrap()
464    }
465
466    fn new_account_pubkey() -> Pubkey {
467        Pubkey::from_base58("DShWnroshVbeUp28oopA3Pu7oFPDBtC1DBmPECXXAQ9n").unwrap()
468    }
469
470    fn random_pubkey() -> Pubkey {
471        let mut bytes = [0u8; 32];
472        bytes
473            .iter_mut()
474            .enumerate()
475            .for_each(|(i, byte)| *byte = i as u8);
476        Pubkey::new(bytes)
477    }
478
479    fn test_blockhash() -> [u8; 32] {
480        let mut bytes = [0u8; 32];
481        bytes
482            .iter_mut()
483            .enumerate()
484            .for_each(|(i, byte)| *byte = i as u8);
485        bytes
486    }
487
488    fn lookup_table_from_sparse_entries(
489        table_key: &str,
490        entries: &[(u8, &str)],
491    ) -> AddressLookupTableAccount {
492        let max_index = entries.iter().map(|(index, _)| *index).max().unwrap_or(0) as usize;
493        let mut addresses: Vec<Pubkey> = (0..=max_index)
494            .map(|entry_index| {
495                let mut bytes = [0u8; 32];
496                bytes[0] = 0xFE;
497                bytes[1] = (entry_index & 0xFF) as u8;
498                bytes[2] = ((entry_index >> 8) & 0xFF) as u8;
499                Pubkey::new(bytes)
500            })
501            .collect();
502
503        for (index, value) in entries {
504            addresses[*index as usize] = Pubkey::from_base58(value).unwrap();
505        }
506
507        AddressLookupTableAccount::new(Pubkey::from_base58(table_key).unwrap(), addresses)
508    }
509
510    fn account_meta_for_combined_index(index: usize, pubkey: Pubkey) -> AccountMeta {
511        let is_signer = index < 2;
512        let is_writable = index < 2 || (2..5).contains(&index) || (13..19).contains(&index);
513        AccountMeta::new(pubkey, is_signer, is_writable)
514    }
515
516    // Derived from `solana decode-transaction <tx_base64> base64 --output json-compact`.
517    fn instruction_from_decoded_tx(
518        program_id_index: usize,
519        account_indexes: &[u8],
520        data_base58: &str,
521        combined_accounts: &[AccountMeta],
522    ) -> Instruction {
523        Instruction {
524            program_id: combined_accounts[program_id_index].pubkey,
525            accounts: account_indexes
526                .iter()
527                .map(|index| combined_accounts[*index as usize].clone())
528                .collect(),
529            data: bs58::decode(data_base58).into_vec().unwrap(),
530        }
531    }
532
533    #[test]
534    fn test_transaction_builder() {
535        let recent_blockhash = "9U2ogLjDt479wubHbEtPLGBF84DijmWggA4KoXSwcivd";
536        let recent_blockhash_bytes = bs58::decode(recent_blockhash).into_vec().unwrap();
537        let fee_payer: Pubkey = "A21o4asMbFHYadqXdLusT9Bvx9xaC5YV9gcaidjqtdXC"
538            .parse()
539            .unwrap();
540        let program_id =
541            Pubkey::from_base58("J88B7gmadHzTNGiy54c9Ms8BsEXNdB2fntFyhKpk3qoT").unwrap();
542        let data = hex::decode("a3265ce2f3698dc400000070000000000100000014000000514bcb1f9aabb904e6106bd1052b66d2706dbbb701000000006c000000000a00000085fba93ee29c604fa858a351688c01290841eafb19c63a70a475d3c7bc3bef9f000000000000000000008489b9cc07af97add00300000000000000000000000000001e83d2972d3dca3a330d60c2777ee5b8d25683c63fa359116985609830f42054050004002d16000000f0314f0cffdf8d00b6a7ce61f86164ca47c1b8b1bc2e").unwrap();
543        let instruction = InstructionBuilder::new(program_id)
544            .data(data)
545            .accounts(vec![
546                AccountMeta::new_readonly(
547                    "ACLMuTFvDAb3oecQQGkTVqpUbhCKHG3EZ9uNXHK1W9ka"
548                        .parse()
549                        .unwrap(),
550                ),
551                AccountMeta::new_writable(
552                    "3tJ67qa2GDfvv2wcMYNUfN5QBZrFpTwcU8ASZKMvCTVU"
553                        .parse()
554                        .unwrap(),
555                ),
556                AccountMeta::new_signer_writable(
557                    "A21o4asMbFHYadqXdLusT9Bvx9xaC5YV9gcaidjqtdXC"
558                        .parse()
559                        .unwrap(),
560                ),
561                AccountMeta::new_writable(
562                    "E8p6aiwuSDWEzQnjGjkNiMZrd1rpSsntWsaZCivdFz51"
563                        .parse()
564                        .unwrap(),
565                ),
566                AccountMeta::new_writable(
567                    "FmAcjWaRFUxGWBfGT7G3CzcFeJFsewQ4KPJVG4f6fcob"
568                        .parse()
569                        .unwrap(),
570                ),
571                AccountMeta::new_readonly(system_program()),
572            ]);
573
574        let mut tx_builder =
575            TransactionBuilder::new(fee_payer, recent_blockhash_bytes.try_into().unwrap());
576        tx_builder.add_instruction(instruction.build());
577
578        let transaction = tx_builder.build().unwrap();
579        let tx_wire_bytes = transaction.serialize_legacy().unwrap();
580        let deserialized_vt = VersionedTransaction::deserialize_with_version(&tx_wire_bytes)
581            .expect("Failed to deserialize wire bytes into VersionedTransaction");
582
583        let _base64_tx = STANDARD.encode(&tx_wire_bytes);
584
585        match deserialized_vt {
586            VersionedTransaction::Legacy {
587                signatures: deserialized_signatures,
588                message: deserialized_legacy_message,
589            } => {
590                assert_eq!(deserialized_signatures, transaction.signatures);
591                assert_eq!(
592                    deserialized_legacy_message.header,
593                    transaction.message.header
594                );
595                assert_eq!(
596                    deserialized_legacy_message.account_keys,
597                    transaction.message.account_keys
598                );
599                assert_eq!(
600                    deserialized_legacy_message.recent_blockhash,
601                    transaction.message.recent_blockhash
602                );
603                assert_eq!(
604                    deserialized_legacy_message.instructions,
605                    transaction.message.instructions
606                );
607            }
608            _ => panic!("Deserialized transaction is not the expected Legacy variant"),
609        }
610    }
611
612    #[test]
613    fn test_complex_transaction() {
614        let payer = payer_pubkey();
615        let blockhash = test_blockhash();
616        let mut tx_builder = TransactionBuilder::new(payer, blockhash);
617
618        let from = payer_pubkey();
619        let new_account = new_account_pubkey();
620        let owner = system_program();
621        let lamports = 1_000_000_000;
622        let space = 165;
623
624        let create_account_ix = create_account(&from, &new_account, lamports, space, &owner);
625        tx_builder.add_instruction(create_account_ix);
626
627        let source = token_pubkey();
628        let dest = random_pubkey();
629        let owner = authority_pubkey();
630        let mint = mint_pubkey();
631        let amount = 1_000_000;
632        let decimals = 6;
633
634        let transfer_ix = transfer_checked(&source, &mint, &dest, &owner, amount, decimals);
635        tx_builder.add_instruction(transfer_ix);
636
637        let transaction = tx_builder.build().unwrap();
638
639        assert!(transaction.signatures.len() >= 2);
640
641        let account_keys = &transaction.message.account_keys;
642        assert!(account_keys.contains(&payer_pubkey()));
643        assert!(account_keys.contains(&new_account));
644        assert!(account_keys.contains(&system_program()));
645        assert!(account_keys.contains(&source));
646        assert!(account_keys.contains(&dest));
647        assert!(account_keys.contains(&owner));
648        assert!(account_keys.contains(&mint));
649        assert!(account_keys.contains(&token_program()));
650        assert_eq!(transaction.message.instructions.len(), 2);
651        assert!(!transaction.message.account_keys.is_empty());
652    }
653
654    #[test]
655    fn test_versioned_transaction_builder_without_lookup_tables() {
656        let fee_payer = payer_pubkey();
657        let recipient = random_pubkey();
658        let recent_blockhash = test_blockhash();
659
660        let transfer_ix = transfer(&fee_payer, &recipient, 123);
661
662        let mut builder = TransactionBuilder::new(fee_payer, recent_blockhash);
663        builder.add_instruction(transfer_ix);
664
665        let transaction = builder.build_v0(&[]).unwrap();
666        let wire_bytes = transaction.serialize().unwrap();
667        let parsed = VersionedTransaction::deserialize_with_version(&wire_bytes).unwrap();
668
669        match parsed {
670            VersionedTransaction::V0 {
671                signatures,
672                message,
673            } => {
674                assert_eq!(signatures.len(), 1);
675                assert_eq!(message.header.num_required_signatures, 1);
676                assert!(message.address_table_lookups.is_empty());
677                assert_eq!(message.instructions.len(), 1);
678            }
679            _ => panic!("expected v0 transaction"),
680        }
681    }
682
683    #[test]
684    fn test_versioned_transaction_builder_with_lookup_table() {
685        let fee_payer = payer_pubkey();
686        let recent_blockhash = test_blockhash();
687        let looked_up_account = Pubkey::new([42u8; 32]);
688        let program_id = Pubkey::new([7u8; 32]);
689
690        let instruction = InstructionBuilder::new(program_id)
691            .account(fee_payer, true, true)
692            .account(looked_up_account, false, true)
693            .data(vec![1, 2, 3])
694            .build();
695
696        let lookup_table = AddressLookupTableAccount::new(
697            Pubkey::new([99u8; 32]),
698            vec![looked_up_account, Pubkey::new([11u8; 32])],
699        );
700
701        let mut builder = TransactionBuilder::new(fee_payer, recent_blockhash);
702        builder.add_instruction(instruction);
703
704        let transaction = builder.build_v0(&[lookup_table]).unwrap();
705        let wire_bytes = transaction.serialize().unwrap();
706        let parsed = VersionedTransaction::deserialize_with_version(&wire_bytes).unwrap();
707
708        match parsed {
709            VersionedTransaction::V0 {
710                signatures,
711                message,
712            } => {
713                assert_eq!(signatures.len(), 1);
714                assert_eq!(message.address_table_lookups.len(), 1);
715                assert_eq!(message.address_table_lookups[0].writable_indexes, vec![0]);
716                assert_eq!(
717                    message.address_table_lookups[0].readonly_indexes,
718                    Vec::<u8>::new()
719                );
720                assert!(!message.account_keys.contains(&looked_up_account));
721                assert_eq!(message.instructions.len(), 1);
722                assert_eq!(message.instructions[0].data, vec![1, 2, 3]);
723            }
724            _ => panic!("expected v0 transaction"),
725        }
726    }
727
728    #[test]
729    fn test_add_instructions_helper() {
730        let fee_payer = payer_pubkey();
731        let recent_blockhash = test_blockhash();
732        let recipient = random_pubkey();
733
734        let ix1 = transfer(&fee_payer, &recipient, 1);
735        let ix2 = transfer(&fee_payer, &recipient, 2);
736
737        let mut builder = TransactionBuilder::new(fee_payer, recent_blockhash);
738        builder.add_instructions(vec![ix1, ix2]);
739
740        let tx = builder.build().unwrap();
741        assert_eq!(tx.message.instructions.len(), 2);
742    }
743
744    #[test]
745    fn test_v0_builder_real_world_regression_case() {
746        // https://solscan.io/tx/2dUtuLXqDEVXppXc6FDP4RRupp2VuHoki8fmR5WqF6aPwAZfcc2wEaRDmjYhhmdDGx6df7kX2ddDhRnfVJvB6egr
747        const REAL_TX_BASE64: &str = "AlF6Dlk4UjQD0xek1R2X8/hcORMjfzZ7/Vmql3hZcmM3+wwWrtvNkbqDFGZqJyFQxlNopEYLGJ3Oo/9gTDqylwOaaKU6sUi0z0x/4AIr2bEbk4F0Bb3eQnlZB2Pd4fwON80kvuBSbQPthCRffekiFXCnIXQUNFcuW3YDiZP0o0oBgAIACA2mI04pxqQuMUitv1NuRlK9ZWJWaV1k+p/LfT3tvKJ+fbIxWsd0GlHg175uFfLQ+Y+1DxMT48DDYU+4V77WYfZ1G4LkfQewG7EXCfCqmCEkGyByWhJU1GOFbK7yr0N338lnQQQP5AeqsFBGoH5xsx9hmNdlxN72v4J91uC6Ksvw/j23WlYbqpa0+YWZyJHXFuu3ghb5vWc1zPY3lpthsJywjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E49RPixdukLDvYMw2r2DTumX5VA1ifoAVfXgkOTnLswDErQ/+if11/ZKdMCbHylYed5LCas238ndUUsyGqezjOXot/ord4dFsTTM4tnKRq1DlX7l6IZI9NWIfD9sANaw+DcFSlNamSkhBk0k6HFg2jh8fDW13bySu4HkH6hAQQVEjeSccqqE9cRgyD3i0H5PnVvX+q6L+uN2Xdbz16thksnaBgUGABAREwYHAQEGAgECDAIAAACApL8HAAAAAAcBAgERCBoHCQECAwQQFBMICAoIFRYNCQ4PAwQUEwcHFyXBIJszQdacgQMBAAAAWQFkAAGApL8HAAAAAGzHtAAAAAAAyAAACwwAAREYGRMQEhoHBQYonVNwIb8yqyWioGpTHjinCEcrRIIzlc3YWKd5g/z9UQsz2pcScMC7SQwAQjB4YzBiZDEyNDczNjVlM2Q2MTMyM2IxYTYyM2YwMzI0MDUzMzU0Yjk2MTJhODkzNTg3YjdlYTMyNjNlN2JhMTNiNAJ5QE4t+Dvx0UlyGT++v3V9s/1gQI0crEMfwbwNXZBmFgPb3xcF3gjcFuH5CleX0p1W2E0BwNC64/nFjEaXTuuVyg5P1Sf64f/vAgMMAwcDAQIA";
748        const STATIC_KEYS: [&str; 13] = [
749            "CBXuKTC3JAHjCvUeCXF2mXJazBqATDExQRxZi1iqQcDa",
750            "CzbDjxK4wqSpBuKfocC9vUgpzfhVEGPh8EihXbQkophA",
751            "2rPmeokZcYM8F3roghsoPYNqSYi32QyrNA9Lm7gN8TDa",
752            "7x4VcEX8aLd3kFsNWULTp1qFgVtDwyWSxpTGQkoMM6XX",
753            "59v2cSbCsnyaWymLnsq6TWzE6cEN5KJYNTBNrcP4smRH",
754            "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
755            "11111111111111111111111111111111",
756            "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
757            "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4",
758            "6U91aKa8pmMxkJwBCfPTmUEfZi6dHe7DcFq2ALvB2tbB",
759            "D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf",
760            "DPArtTLbEqa6EuXHfL5UFLBZhFjiEXWRudhvXDrjwXUr",
761            "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
762        ];
763        const LOADED_WRITABLE: [&str; 6] = [
764            "FLckHLGMJy5gEoXWwcE68Nprde1D4araK4TGLw4pQq2n",
765            "5pVN5XZB8cYBjNLFrsBCPWkCQBan5K5Mq2dWGzwPgGJV",
766            "9t4P5wMwfFkyn92Z7hf463qYKEZf8ERVZsGBEPNp8uJx",
767            "H2DG3qk1cRqBUmRNjJ2fsGrGs47NQk5VRBLt1AevW8m2",
768            "6GpvpHXBJA7pW8gP9KEXBJ2spNyydmbY5Q4nbdoo5TeT",
769            "4nvJ5zWdVspxJiNZzB127U6amPH98SFFkBx2JZrAduia",
770        ];
771        const LOADED_READONLY: [&str; 8] = [
772            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
773            "So11111111111111111111111111111111111111112",
774            "TessVdML9pBGgG9yGks7o4HewRaXVAMuoVj4x83GLQH",
775            "8ekCy2jHHUbW2yeNGFWYJT9Hm9FW7SvZcZK66dSZCDiF",
776            "Sysvar1nstructions1111111111111111111111111",
777            "Dodg2HifwU8rmaVVyMyUZDGTRbqAJTyVYxXPwcbNpBKc",
778            "7uTT8Xi5RWXzy7h9XL244GRgEycDYDhLjr3ZyNdXi8pZ",
779            "99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2",
780        ];
781
782        let wire = STANDARD.decode(REAL_TX_BASE64).unwrap();
783        let mut expected_tx = VersionedTransaction::deserialize_with_version(&wire).unwrap();
784        for signature in expected_tx.signatures_mut() {
785            *signature = SignatureBytes::default();
786        }
787
788        let (fee_payer, recent_blockhash) = match &expected_tx {
789            VersionedTransaction::V0 { message, .. } => {
790                (message.account_keys[0], message.recent_blockhash)
791            }
792            _ => panic!("expected V0 transaction fixture"),
793        };
794
795        let combined_accounts: Vec<AccountMeta> = STATIC_KEYS
796            .iter()
797            .chain(LOADED_WRITABLE.iter())
798            .chain(LOADED_READONLY.iter())
799            .copied()
800            .map(|value| Pubkey::from_base58(value).unwrap())
801            .enumerate()
802            .map(|(index, key)| account_meta_for_combined_index(index, key))
803            .collect();
804
805        let lookup_tables = vec![
806            lookup_table_from_sparse_entries(
807                "9AKCoNoAGYLW71TwTHY9e7KrZUWWL3c7VtHKb66NT3EV",
808                &[
809                    (219, "FLckHLGMJy5gEoXWwcE68Nprde1D4araK4TGLw4pQq2n"),
810                    (223, "5pVN5XZB8cYBjNLFrsBCPWkCQBan5K5Mq2dWGzwPgGJV"),
811                    (23, "9t4P5wMwfFkyn92Z7hf463qYKEZf8ERVZsGBEPNp8uJx"),
812                    (222, "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"),
813                    (8, "So11111111111111111111111111111111111111112"),
814                    (220, "TessVdML9pBGgG9yGks7o4HewRaXVAMuoVj4x83GLQH"),
815                    (22, "8ekCy2jHHUbW2yeNGFWYJT9Hm9FW7SvZcZK66dSZCDiF"),
816                    (225, "Sysvar1nstructions1111111111111111111111111"),
817                ],
818            ),
819            lookup_table_from_sparse_entries(
820                "Hm9fUgcn7qwDaiNTFiGh6pNtVATgnaRcmK6Bbx6EMZfP",
821                &[
822                    (12, "H2DG3qk1cRqBUmRNjJ2fsGrGs47NQk5VRBLt1AevW8m2"),
823                    (3, "6GpvpHXBJA7pW8gP9KEXBJ2spNyydmbY5Q4nbdoo5TeT"),
824                    (7, "4nvJ5zWdVspxJiNZzB127U6amPH98SFFkBx2JZrAduia"),
825                    (1, "Dodg2HifwU8rmaVVyMyUZDGTRbqAJTyVYxXPwcbNpBKc"),
826                    (2, "7uTT8Xi5RWXzy7h9XL244GRgEycDYDhLjr3ZyNdXi8pZ"),
827                    (0, "99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2"),
828                ],
829            ),
830        ];
831
832        let instructions = vec![
833            instruction_from_decoded_tx(5, &[0, 16, 17, 19, 6, 7], "2", &combined_accounts),
834            instruction_from_decoded_tx(6, &[1, 2], "3Bxs4NNfTBw5NH5H", &combined_accounts),
835            instruction_from_decoded_tx(7, &[2], "J", &combined_accounts),
836            instruction_from_decoded_tx(
837                8,
838                &[
839                    7, 9, 1, 2, 3, 4, 16, 20, 19, 8, 8, 10, 8, 21, 22, 13, 9, 14, 15, 3, 4, 20, 19,
840                    7, 7, 23,
841                ],
842                "7UR2vxkjV6WhbmWvkCZQvQJKVhT964yPqVRoTBAPv678iyHS8LF",
843                &combined_accounts,
844            ),
845            instruction_from_decoded_tx(
846                11,
847                &[0, 1, 17, 24, 25, 19, 16, 18, 26, 7, 5, 6],
848                "8pPpkivb1mTLA5APTUWQU2CsG1oYxcnh5C8fQsYux2BVVxSXuFvWtLx",
849                &combined_accounts,
850            ),
851            instruction_from_decoded_tx(
852                12,
853                &[],
854                "KszMTKrqxdHWZULtjrD9cmodXEnC1UboEfkgMRLSGuuPLDYWo8BrqcbfRddG4w18gsf1sZR69vK1mKhXyvNCTZxwsq",
855                &combined_accounts,
856            ),
857        ];
858
859        let mut builder = TransactionBuilder::new(fee_payer, recent_blockhash);
860        builder.add_instructions(instructions);
861        let rebuilt_tx = builder.build_v0(&lookup_tables).unwrap();
862
863        assert_eq!(
864            rebuilt_tx.serialize().unwrap(),
865            expected_tx.serialize().unwrap()
866        );
867    }
868
869    #[test]
870    fn test_build_rejects_more_than_256_distinct_accounts() {
871        let recent_blockhash = test_blockhash();
872
873        let distinct_pubkey = |index: u32| -> Pubkey {
874            let mut bytes = [0u8; 32];
875            bytes[0..4].copy_from_slice(&index.to_le_bytes());
876            Pubkey::new(bytes)
877        };
878
879        let fee_payer = distinct_pubkey(0);
880        let program_id = distinct_pubkey(1);
881
882        let accounts: Vec<AccountMeta> = (2..257)
883            .map(|index| AccountMeta::new_writable(distinct_pubkey(index)))
884            .collect();
885
886        let instruction = Instruction {
887            program_id,
888            accounts,
889            data: vec![],
890        };
891
892        let mut builder = TransactionBuilder::new(fee_payer, recent_blockhash);
893        builder.add_instruction(instruction);
894
895        let result = builder.build();
896        assert!(
897            matches!(result, Err(SolanaError::InvalidMessage)),
898            "expected build() to reject 257 distinct accounts with InvalidMessage, got {result:?}"
899        );
900    }
901
902    #[test]
903    fn test_build_rejects_256_required_signers() {
904        let recent_blockhash = test_blockhash();
905
906        let distinct_pubkey = |index: u32| -> Pubkey {
907            let mut bytes = [0u8; 32];
908            bytes[0..4].copy_from_slice(&index.to_le_bytes());
909            Pubkey::new(bytes)
910        };
911
912        let fee_payer = distinct_pubkey(0);
913        // 256 signers total: passes the account-count check but wraps to 0 as u8 without the fix.
914        let signer_pubkeys: Vec<Pubkey> = (1..256).map(distinct_pubkey).collect();
915        let program_id = signer_pubkeys[0];
916
917        let accounts: Vec<AccountMeta> = signer_pubkeys
918            .iter()
919            .map(|pubkey| AccountMeta::new_signer_writable(*pubkey))
920            .collect();
921
922        let instruction = Instruction {
923            program_id,
924            accounts,
925            data: vec![],
926        };
927
928        let mut builder = TransactionBuilder::new(fee_payer, recent_blockhash);
929        builder.add_instruction(instruction);
930
931        let result = builder.build();
932        assert!(
933            matches!(result, Err(SolanaError::InvalidMessage)),
934            "expected build() to reject 256 required signers with InvalidMessage, got {result:?}"
935        );
936    }
937}