Skip to main content

lichen_core/
transaction.rs

1// Lichen Core - Transaction Model
2
3use crate::account::{Keypair, PqSignature, Pubkey};
4use crate::hash::Hash;
5use serde::{Deserialize, Serialize};
6use std::collections::HashSet;
7
8/// Single instruction in a transaction
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Instruction {
11    /// Program to invoke
12    pub program_id: Pubkey,
13
14    /// Accounts involved
15    pub accounts: Vec<Pubkey>,
16
17    /// Instruction data
18    pub data: Vec<u8>,
19}
20
21/// Default compute unit budget per transaction (200,000 CU).
22/// Users can request up to [`MAX_COMPUTE_BUDGET`] by setting
23/// `Message::compute_budget`.
24pub const DEFAULT_COMPUTE_BUDGET: u64 = 200_000;
25
26/// Maximum compute unit budget a transaction may request (1,400,000 CU).
27/// Mirrors Solana's per-transaction CU ceiling.
28pub const MAX_COMPUTE_BUDGET: u64 = 1_400_000;
29
30/// Transaction message (before signing)
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Message {
33    /// Instructions to execute
34    pub instructions: Vec<Instruction>,
35
36    /// Recent blockhash (for replay protection)
37    pub recent_blockhash: Hash,
38
39    /// Compute unit budget for this transaction.
40    /// If `None` or `0`, defaults to [`DEFAULT_COMPUTE_BUDGET`] (200,000 CU).
41    /// Maximum allowed: [`MAX_COMPUTE_BUDGET`] (1,400,000 CU).
42    /// If execution exceeds this budget the transaction reverts and the
43    /// base fee is still charged (anti-DoS).
44    #[serde(default)]
45    pub compute_budget: Option<u64>,
46
47    /// Price per compute unit in micro-spores (μspores).
48    /// Priority fee = `effective_compute_budget × compute_unit_price`.
49    /// Set to `0` (default) for no priority fee. Validators order
50    /// transactions by effective CU price for block inclusion.
51    #[serde(default)]
52    pub compute_unit_price: Option<u64>,
53}
54
55impl Message {
56    pub fn new(instructions: Vec<Instruction>, recent_blockhash: Hash) -> Self {
57        Message {
58            instructions,
59            recent_blockhash,
60            compute_budget: None,
61            compute_unit_price: None,
62        }
63    }
64
65    /// Effective compute budget — resolves `None`/`0` to the protocol default.
66    pub fn effective_compute_budget(&self) -> u64 {
67        match self.compute_budget {
68            Some(b) if b > 0 => b.min(MAX_COMPUTE_BUDGET),
69            _ => DEFAULT_COMPUTE_BUDGET,
70        }
71    }
72
73    /// Effective compute unit price in micro-spores.
74    pub fn effective_compute_unit_price(&self) -> u64 {
75        self.compute_unit_price.unwrap_or(0)
76    }
77
78    /// Serialize for signing.
79    ///
80    /// Panics only on OOM or bincode internal error (neither expected for a
81    /// well-formed Message). Callers that need fallibility should use
82    /// `try_serialize()` instead.
83    pub fn serialize(&self) -> Vec<u8> {
84        bincode::serialize(self).unwrap_or_else(|e| {
85            panic!(
86                "FATAL: Message serialization failed ({}). This indicates data corruption or OOM.",
87                e
88            )
89        })
90    }
91
92    /// Fallible serialization for contexts that can propagate errors.
93    pub fn try_serialize(&self) -> Result<Vec<u8>, String> {
94        bincode::serialize(self).map_err(|e| format!("Message serialization failed: {}", e))
95    }
96
97    /// Hash for signing
98    pub fn hash(&self) -> Hash {
99        Hash::hash(&self.serialize())
100    }
101}
102
103/// Transaction type discriminator — replaces sentinel-based detection.
104///
105/// - `Native`: Standard Lichen transaction (PQ signed, blockhash replay protection)
106/// - `Evm`: EVM-wrapped transaction (ECDSA signed, EVM nonce replay protection)
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
108pub enum TransactionType {
109    #[default]
110    Native,
111    Evm,
112}
113
114/// Wire-format magic bytes identifying a Lichen transaction envelope.
115/// "MT" = fixed magic prefix. The pair `[0x4D, 0x54]` cannot appear as the first
116/// two bytes of a raw-bincode Transaction (that would imply 0x544D = 21,581
117/// signatures, which is impossible).
118pub const TX_WIRE_MAGIC: [u8; 2] = [0x4D, 0x54];
119
120/// Current wire-format version.
121pub const TX_WIRE_VERSION: u8 = 1;
122
123/// Signed transaction
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct Transaction {
126    /// Transaction signatures. Each signature is self-contained: it carries both
127    /// the signature bytes and the signer's PQ verifying key.
128    pub signatures: Vec<PqSignature>,
129
130    /// Transaction message
131    pub message: Message,
132
133    /// Transaction type — determines processing path.
134    /// Defaults to `Native`.
135    #[serde(default)]
136    pub tx_type: TransactionType,
137}
138
139/// Maximum instructions per transaction (T1.7)
140pub const MAX_INSTRUCTIONS_PER_TX: usize = 64;
141/// Maximum self-contained PQ signatures per transaction.
142///
143/// Each instruction contributes at most one required signer (its first account),
144/// so accepting more signatures than instructions only increases bandwidth,
145/// memory, and verification work without adding valid authorization surface.
146pub const MAX_SIGNATURES_PER_TX: usize = MAX_INSTRUCTIONS_PER_TX;
147/// Maximum data bytes per instruction (T1.7)
148pub const MAX_INSTRUCTION_DATA: usize = 204_800; // 200KB — contract calls may carry significant payloads
149pub const MAX_DEPLOY_INSTRUCTION_DATA: usize = 4_194_304; // 4MB — WASM deploys via instruction type 17
150/// Maximum bincode-serialized transaction size.
151///
152/// This leaves room for one max-size deploy instruction plus PQ signatures and
153/// metadata, while preventing many individually-valid instructions or excess
154/// signatures from producing transactions too large for admission.
155pub const MAX_TRANSACTION_SERIALIZED_SIZE: u64 = 5 * 1024 * 1024;
156/// Maximum accounts per instruction
157pub const MAX_ACCOUNTS_PER_IX: usize = 64;
158
159impl Transaction {
160    pub fn new(message: Message) -> Self {
161        Transaction {
162            signatures: Vec::new(),
163            message,
164            tx_type: TransactionType::Native,
165        }
166    }
167
168    /// Create a new EVM-typed transaction.
169    pub fn new_evm(message: Message) -> Self {
170        Transaction {
171            signatures: Vec::new(),
172            message,
173            tx_type: TransactionType::Evm,
174        }
175    }
176
177    /// Check if this is an EVM transaction (by type field or EVM replay sentinel).
178    pub fn is_evm(&self) -> bool {
179        self.tx_type == TransactionType::Evm
180            || self.message.recent_blockhash == crate::Hash([0xEE; 32])
181    }
182
183    /// Get transaction signature (first signature's identifier)
184    pub fn signature(&self) -> Hash {
185        self.hash()
186    }
187
188    /// Get the message-only hash (signing hash).
189    ///
190    /// This is the hash that signers commit to via PQ signatures. It does NOT include
191    /// signatures, so it is predictable before signing — useful for multi-sig
192    /// coordination and client-side txid tracking before broadcast.
193    ///
194    /// See also: `hash()` which includes signatures and serves as the canonical txid.
195    pub fn message_hash(&self) -> Hash {
196        self.message.hash()
197    }
198
199    /// Get the sender/fee-payer (first account of first instruction)
200    pub fn sender(&self) -> Pubkey {
201        self.message.instructions[0].accounts[0]
202    }
203
204    /// Get transaction hash (includes the full signed envelope).
205    ///
206    /// This is the **canonical transaction ID** stored in `CF_TRANSACTIONS` and
207    /// returned by RPC methods. It equals `SHA-256(bincode(transaction))`.
208    ///
209    /// Using the full serialized transaction keeps the txid aligned with the
210    /// actual bytes propagated over the network, including the self-contained PQ
211    /// signatures that carry signer public keys.
212    pub fn hash(&self) -> Hash {
213        let data = bincode::serialize(self).expect("Transaction bincode serialization failed");
214        Hash::hash(&data)
215    }
216
217    /// Collect the signer accounts required by this transaction.
218    pub fn required_signers(&self) -> Result<HashSet<Pubkey>, String> {
219        if self.message.instructions.is_empty() {
220            return Err("No instructions".to_string());
221        }
222
223        let mut required_signers = HashSet::new();
224        for ix in &self.message.instructions {
225            let Some(first_account) = ix.accounts.first() else {
226                return Err("Instruction has no accounts".to_string());
227            };
228            required_signers.insert(*first_account);
229        }
230
231        Ok(required_signers)
232    }
233
234    /// Verify that every required signer has a valid PQ signature over the
235    /// serialized transaction message.
236    pub fn verify_required_signatures(&self) -> Result<HashSet<Pubkey>, String> {
237        if self.signatures.is_empty() {
238            return Err("No signatures".to_string());
239        }
240
241        let required_signers = self.required_signers()?;
242        if self.signatures.len() < required_signers.len() {
243            return Err(format!(
244                "Insufficient signatures: got {}, need {}",
245                self.signatures.len(),
246                required_signers.len()
247            ));
248        }
249
250        let message_bytes = self.message.serialize();
251        let mut verified_signers = HashSet::with_capacity(required_signers.len());
252
253        for signature in &self.signatures {
254            let signer = signature.signer_address();
255            if !required_signers.contains(&signer) || verified_signers.contains(&signer) {
256                continue;
257            }
258
259            if Keypair::verify(&signer, &message_bytes, signature) {
260                verified_signers.insert(signer);
261            }
262        }
263
264        for signer in &required_signers {
265            if !verified_signers.contains(signer) {
266                return Err(format!(
267                    "Missing or invalid signature for account {}",
268                    signer
269                ));
270            }
271        }
272
273        Ok(verified_signers)
274    }
275
276    /// Validate transaction structure (size limits, T1.7)
277    pub fn validate_structure(&self) -> Result<(), String> {
278        if self.signatures.len() > MAX_SIGNATURES_PER_TX {
279            return Err(format!(
280                "Too many signatures: {} (max {})",
281                self.signatures.len(),
282                MAX_SIGNATURES_PER_TX
283            ));
284        }
285        if self.message.instructions.is_empty() {
286            return Err("No instructions".to_string());
287        }
288        if self.message.instructions.len() > MAX_INSTRUCTIONS_PER_TX {
289            return Err(format!(
290                "Too many instructions: {} (max {})",
291                self.message.instructions.len(),
292                MAX_INSTRUCTIONS_PER_TX
293            ));
294        }
295        for (i, ix) in self.message.instructions.iter().enumerate() {
296            // Deploy instructions allow up to 4MB for WASM code:
297            // - System program type 17 (system_deploy_contract)
298            // - Contract program Deploy variant (JSON-encoded WASM via ContractInstruction)
299            let is_system_deploy = ix.program_id == crate::Pubkey([0u8; 32])
300                && !ix.data.is_empty()
301                && ix.data[0] == 17;
302            let is_contract_deploy =
303                ix.program_id == crate::Pubkey([0xFFu8; 32]) && ix.data.starts_with(b"{\"Deploy\"");
304            let data_limit = if is_system_deploy || is_contract_deploy {
305                MAX_DEPLOY_INSTRUCTION_DATA
306            } else {
307                MAX_INSTRUCTION_DATA
308            };
309            if ix.data.len() > data_limit {
310                return Err(format!(
311                    "Instruction {} data too large: {} bytes (max {})",
312                    i,
313                    ix.data.len(),
314                    data_limit
315                ));
316            }
317            if ix.accounts.len() > MAX_ACCOUNTS_PER_IX {
318                return Err(format!(
319                    "Instruction {} has too many accounts: {} (max {})",
320                    i,
321                    ix.accounts.len(),
322                    MAX_ACCOUNTS_PER_IX
323                ));
324            }
325        }
326        let serialized_size = bincode::serialized_size(self)
327            .map_err(|e| format!("Transaction size serialization failed: {}", e))?;
328        if serialized_size > MAX_TRANSACTION_SERIALIZED_SIZE {
329            return Err(format!(
330                "Transaction serialized size too large: {} bytes (max {})",
331                serialized_size, MAX_TRANSACTION_SERIALIZED_SIZE
332            ));
333        }
334        Ok(())
335    }
336
337    // ── Wire-format envelope (M-6) ─────────────────────────────
338
339    /// Serialize to the V1 wire envelope: `[magic_0, magic_1, version, type, ...bincode]`.
340    ///
341    /// Callers that need base64 transport can encode the returned bytes with
342    /// `base64::encode(&tx.to_wire())`.
343    pub fn to_wire(&self) -> Vec<u8> {
344        let payload = bincode::serialize(self).expect("Transaction bincode serialization failed");
345        let mut buf = Vec::with_capacity(4 + payload.len());
346        buf.extend_from_slice(&TX_WIRE_MAGIC);
347        buf.push(TX_WIRE_VERSION);
348        buf.push(self.tx_type as u8);
349        buf.extend_from_slice(&payload);
350        buf
351    }
352
353    /// Deserialize from wire bytes, supporting three formats:
354    ///
355    /// 1. **V1 envelope** — starts with `TX_WIRE_MAGIC` (`[0x4D, 0x54]`)
356    /// 2. **Raw bincode** — `bincode::serialize(&Transaction)` output
357    /// 3. **JSON** — `{ "signatures": [...], "message": {...} }` from browser wallets
358    ///
359    /// The `max_wire_bytes` parameter caps both JSON and bincode payloads before
360    /// deserialization to prevent OOM from adversarial transaction submissions.
361    pub fn from_wire(data: &[u8], max_wire_bytes: u64) -> Result<Self, String> {
362        if data.len() as u64 > max_wire_bytes {
363            return Err(format!(
364                "Transaction wire payload too large: {} bytes (max {})",
365                data.len(),
366                max_wire_bytes
367            ));
368        }
369
370        // --- V1 envelope ---
371        if data.len() >= 4 && data[0..2] == TX_WIRE_MAGIC {
372            let version = data[2];
373            if version != TX_WIRE_VERSION {
374                return Err(format!("Unsupported wire version: {}", version));
375            }
376            let type_byte = data[3];
377            let tx_type = match type_byte {
378                0 => TransactionType::Native,
379                1 => TransactionType::Evm,
380                _ => return Err(format!("Unknown transaction type byte: {}", type_byte)),
381            };
382            let payload = &data[4..];
383            let mut tx: Self = bounded_bincode_deser(payload, max_wire_bytes)?;
384            // Envelope type is authoritative
385            tx.tx_type = tx_type;
386            return Ok(tx);
387        }
388
389        // --- JSON vs bincode ---
390        if data.first() == Some(&b'{') {
391            // Looks like JSON — try JSON first, fall back to bincode
392            json_deser(data).or_else(|_| bounded_bincode_deser(data, max_wire_bytes))
393        } else {
394            // Try bincode first, fall back to JSON
395            bounded_bincode_deser(data, max_wire_bytes).or_else(|_| json_deser(data))
396        }
397    }
398}
399
400/// Bounded bincode deserialization with panic catch (bincode 1.x safety).
401fn bounded_bincode_deser(bytes: &[u8], limit: u64) -> Result<Transaction, String> {
402    use bincode::Options;
403    match std::panic::catch_unwind(|| {
404        bincode::options()
405            .with_limit(limit)
406            .with_fixint_encoding()
407            .allow_trailing_bytes()
408            .deserialize(bytes)
409    }) {
410        Ok(Ok(tx)) => Ok(tx),
411        Ok(Err(e)) => Err(format!("bincode: {}", e)),
412        Err(_) => Err("bincode panicked during deserialization".to_string()),
413    }
414}
415
416/// Attempt JSON deserialization of a wallet-format transaction.
417fn json_deser(bytes: &[u8]) -> Result<Transaction, String> {
418    serde_json::from_slice(bytes).map_err(|e| format!("JSON: {}", e))
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn test_transaction_creation() {
427        let program_id = Pubkey([1u8; 32]);
428        let accounts = vec![Pubkey([2u8; 32]), Pubkey([3u8; 32])];
429
430        let instruction = Instruction {
431            program_id,
432            accounts,
433            data: vec![0, 1, 2, 3],
434        };
435
436        let message = Message::new(vec![instruction], Hash::hash(b"recent_block"));
437
438        let tx = Transaction::new(message);
439
440        println!("Transaction signature: {}", tx.signature());
441        assert_eq!(tx.signatures.len(), 0); // Not signed yet
442    }
443
444    // ── H16 tests: deploy instruction data limit exemption ──
445
446    #[test]
447    fn test_validate_structure_normal_instruction_200kb_limit() {
448        let ix = Instruction {
449            program_id: Pubkey([1u8; 32]),
450            accounts: vec![Pubkey([2u8; 32])],
451            data: vec![0u8; MAX_INSTRUCTION_DATA + 1],
452        };
453        let msg = Message::new(vec![ix], Hash::default());
454        let tx = Transaction::new(msg);
455        assert!(tx.validate_structure().is_err());
456    }
457
458    #[test]
459    fn test_validate_structure_deploy_instruction_allows_large_data() {
460        // System program (all zeros), instruction type 17 = DeployContract
461        let mut data = vec![17u8]; // type byte
462        data.extend_from_slice(&(100_000u32).to_le_bytes()); // code_length
463        data.extend(vec![0u8; 100_000]); // fake WASM code (100KB — within 200KB general limit but tests deploy path)
464
465        let ix = Instruction {
466            program_id: Pubkey([0u8; 32]), // system program
467            accounts: vec![Pubkey([2u8; 32]), Pubkey([3u8; 32])],
468            data,
469        };
470        let msg = Message::new(vec![ix], Hash::default());
471        let tx = Transaction::new(msg);
472        assert!(
473            tx.validate_structure().is_ok(),
474            "Deploy instruction should allow >200KB data"
475        );
476    }
477
478    #[test]
479    fn test_validate_structure_deploy_instruction_4mb_limit() {
480        // Even deploy instructions have a 4MB cap
481        let mut data = vec![17u8];
482        data.extend(vec![0u8; MAX_DEPLOY_INSTRUCTION_DATA - 1]); // total = limit (type byte + payload)
483        let ix = Instruction {
484            program_id: Pubkey([0u8; 32]),
485            accounts: vec![Pubkey([2u8; 32])],
486            data,
487        };
488        let msg = Message::new(vec![ix], Hash::default());
489        let tx = Transaction::new(msg);
490        assert!(tx.validate_structure().is_ok());
491
492        // Over limit
493        let mut data2 = vec![17u8];
494        data2.extend(vec![0u8; MAX_DEPLOY_INSTRUCTION_DATA + 1]);
495        let ix2 = Instruction {
496            program_id: Pubkey([0u8; 32]),
497            accounts: vec![Pubkey([2u8; 32])],
498            data: data2,
499        };
500        let msg2 = Message::new(vec![ix2], Hash::default());
501        let tx2 = Transaction::new(msg2);
502        assert!(
503            tx2.validate_structure().is_err(),
504            "Deploy instruction over 4MB should be rejected"
505        );
506    }
507
508    #[test]
509    fn test_validate_structure_rejects_too_many_signatures() {
510        let ix = Instruction {
511            program_id: Pubkey([1u8; 32]),
512            accounts: vec![Pubkey([2u8; 32])],
513            data: vec![0],
514        };
515        let msg = Message::new(vec![ix], Hash::default());
516        let mut tx = Transaction::new(msg);
517        tx.signatures = (0..=MAX_SIGNATURES_PER_TX)
518            .map(|idx| crate::account::PqSignature::test_fixture(idx as u8))
519            .collect();
520
521        let result = tx.validate_structure();
522        assert!(result.is_err());
523        assert!(result.unwrap_err().contains("Too many signatures"));
524    }
525
526    #[test]
527    fn test_validate_structure_rejects_serialized_tx_size_over_limit() {
528        let instructions = (0..26)
529            .map(|idx| Instruction {
530                program_id: Pubkey([idx as u8; 32]),
531                accounts: vec![Pubkey([2u8; 32])],
532                data: vec![idx as u8; MAX_INSTRUCTION_DATA],
533            })
534            .collect();
535        let tx = Transaction::new(Message::new(instructions, Hash::default()));
536
537        let serialized_size = bincode::serialized_size(&tx).unwrap();
538        assert!(
539            serialized_size > MAX_TRANSACTION_SERIALIZED_SIZE,
540            "fixture must exceed tx serialized-size cap"
541        );
542
543        let result = tx.validate_structure();
544        assert!(result.is_err());
545        assert!(result.unwrap_err().contains("serialized size too large"));
546    }
547
548    #[test]
549    fn test_from_wire_rejects_oversized_payload_before_deserialization() {
550        let bytes = vec![b'{'; MAX_TRANSACTION_SERIALIZED_SIZE as usize + 1];
551
552        let result = Transaction::from_wire(&bytes, MAX_TRANSACTION_SERIALIZED_SIZE);
553        assert!(result.is_err());
554        assert!(result.unwrap_err().contains("wire payload too large"));
555    }
556
557    // ── AUDIT-FIX A3-01: Verify data field IS included in signature hash ──
558
559    /// Regression test: changing instruction data MUST produce a different
560    /// message hash and different signature. This prevents the old vulnerability
561    /// where `data` was excluded from the signed hash.
562    #[test]
563    fn test_a3_01_data_field_included_in_signature_hash() {
564        let bh = Hash::default();
565
566        // Two instructions identical except for data
567        let ix1 = Instruction {
568            program_id: Pubkey([1u8; 32]),
569            accounts: vec![Pubkey([2u8; 32])],
570            data: vec![0x01, 0x02, 0x03],
571        };
572        let ix2 = Instruction {
573            program_id: Pubkey([1u8; 32]),
574            accounts: vec![Pubkey([2u8; 32])],
575            data: vec![0x01, 0x02, 0x04], // only last byte differs
576        };
577
578        let msg1 = Message::new(vec![ix1], bh);
579        let msg2 = Message::new(vec![ix2], bh);
580
581        // Serialized bytes must differ
582        assert_ne!(
583            msg1.serialize(),
584            msg2.serialize(),
585            "A3-01 REGRESSION: Messages with different data must serialize differently"
586        );
587
588        // Hashes must differ
589        assert_ne!(
590            msg1.hash(),
591            msg2.hash(),
592            "A3-01 REGRESSION: Messages with different data must hash differently"
593        );
594    }
595
596    /// Regression test: changing program_id MUST produce a different hash.
597    #[test]
598    fn test_a3_01_program_id_included_in_signature_hash() {
599        let bh = Hash::default();
600
601        let ix1 = Instruction {
602            program_id: Pubkey([1u8; 32]),
603            accounts: vec![Pubkey([2u8; 32])],
604            data: vec![0x01],
605        };
606        let ix2 = Instruction {
607            program_id: Pubkey([99u8; 32]), // different program
608            accounts: vec![Pubkey([2u8; 32])],
609            data: vec![0x01],
610        };
611
612        let msg1 = Message::new(vec![ix1], bh);
613        let msg2 = Message::new(vec![ix2], bh);
614
615        assert_ne!(
616            msg1.hash(),
617            msg2.hash(),
618            "A3-01 REGRESSION: Messages with different program_id must hash differently"
619        );
620    }
621
622    /// Regression test: changing accounts MUST produce a different hash.
623    #[test]
624    fn test_a3_01_accounts_included_in_signature_hash() {
625        let bh = Hash::default();
626
627        let ix1 = Instruction {
628            program_id: Pubkey([1u8; 32]),
629            accounts: vec![Pubkey([2u8; 32])],
630            data: vec![0x01],
631        };
632        let ix2 = Instruction {
633            program_id: Pubkey([1u8; 32]),
634            accounts: vec![Pubkey([3u8; 32])], // different account
635            data: vec![0x01],
636        };
637
638        let msg1 = Message::new(vec![ix1], bh);
639        let msg2 = Message::new(vec![ix2], bh);
640
641        assert_ne!(
642            msg1.hash(),
643            msg2.hash(),
644            "A3-01 REGRESSION: Messages with different accounts must hash differently"
645        );
646    }
647
648    // ════════════════════════════════════════════════════════════════════
649    // K4-02: Cross-SDK serialization compatibility golden vector
650    // ════════════════════════════════════════════════════════════════════
651
652    /// Generate a deterministic Message, serialize it via bincode, and assert
653    /// the exact bytes match the golden vector. JS and Python SDKs MUST produce
654    /// identical output for the same input. If this test changes, all SDK tests
655    /// must be updated.
656    #[test]
657    fn test_cross_sdk_message_golden_vector() {
658        let ix = Instruction {
659            program_id: Pubkey([1u8; 32]),
660            accounts: vec![Pubkey([2u8; 32])],
661            data: vec![0x00, 0x01, 0x02, 0x03],
662        };
663        let msg = Message {
664            instructions: vec![ix],
665            recent_blockhash: crate::Hash::new([0xAA; 32]),
666            compute_budget: None,
667            compute_unit_price: None,
668        };
669
670        let bytes = msg.serialize();
671        let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
672
673        // Print for reference if generating new golden vector:
674        // eprintln!("GOLDEN_VECTOR_HEX={}", hex);
675
676        // Golden vector (bincode 1.3 default serialization):
677        // instructions: Vec<Instruction> → u64_le(1) + Instruction
678        //   program_id: [u8; 32] → 32 raw bytes (0x01 repeated)
679        //   accounts: Vec<Pubkey> → u64_le(1) + 32 raw bytes (0x02 repeated)
680        //   data: Vec<u8> → u64_le(4) + [0x00, 0x01, 0x02, 0x03]
681        // recent_blockhash: [u8; 32] → 32 raw bytes (0xAA repeated)
682        let expected = format!(
683            "{}{}{}{}{}{}{}",
684            "0100000000000000", // Vec<Ix> len = 1
685            "0101010101010101010101010101010101010101010101010101010101010101", // program_id
686            "0100000000000000", // Vec<Pubkey> len = 1
687            "0202020202020202020202020202020202020202020202020202020202020202", // accounts[0]
688            "040000000000000000010203", // Vec<u8> len=4 + data
689            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // blockhash
690            "0000",             // compute_budget: None (0x00) + compute_unit_price: None (0x00)
691        );
692
693        assert_eq!(
694            hex, expected,
695            "K4-02 GOLDEN VECTOR MISMATCH!\n\
696             This means the Rust bincode serialization changed.\n\
697             JS/Python SDKs MUST also match this exact byte sequence.\n\
698             Got:      {}\n\
699             Expected: {}",
700            hex, expected
701        );
702    }
703
704    /// Golden vector for a full Transaction (signature + message).
705    #[test]
706    fn test_cross_sdk_transaction_golden_vector() {
707        let ix = Instruction {
708            program_id: Pubkey([1u8; 32]),
709            accounts: vec![Pubkey([2u8; 32])],
710            data: vec![0x00, 0x01, 0x02, 0x03],
711        };
712        let msg = Message {
713            instructions: vec![ix],
714            recent_blockhash: crate::Hash::new([0xAA; 32]),
715            compute_budget: None,
716            compute_unit_price: None,
717        };
718        let sig = crate::account::PqSignature::test_fixture(0xBB);
719        let tx = Transaction {
720            signatures: vec![sig],
721            message: msg,
722            tx_type: Default::default(),
723        };
724
725        let bytes = bincode::serialize(&tx).expect("tx serialization");
726        let tx_hash = Hash::hash(&bytes);
727
728        assert_eq!(
729            bytes.len(),
730            5_417,
731            "unexpected serialized PQ transaction length"
732        );
733        assert_eq!(
734            tx_hash,
735            Hash::from_hex("9d0eec7b657276b828c265995ce78b41a3e19b17ab354b11f37254bbc4ee2a91")
736                .unwrap()
737        );
738    }
739}