@@ -354,5 +354,342 @@ def test_solana_sign_memo(self):
354354 self .assertEqual (len (resp .signature ), 64 )
355355
356356
357+ # ================================================================
358+ # Negative / rejection tests
359+ # ================================================================
360+
361+ def test_solana_sign_malformed_truncated (self ):
362+ """Reject raw_tx that is too short to contain header + accounts."""
363+ self .requires_fullFeature ()
364+ self .setup_mnemonic_allallall ()
365+
366+ raw_tx = b'\x00 \x01 \x00 \x01 \x01 ' # 5 bytes — header says 1 account but no data
367+
368+ with pytest .raises (CallException ):
369+ self .client .call (messages .SolanaSignTx (
370+ address_n = parse_path ("m/44'/501'/0'/0'" ),
371+ raw_tx = raw_tx ,
372+ ))
373+
374+ def test_solana_sign_malformed_bad_account_count (self ):
375+ """Reject raw_tx whose header claims 33 accounts (exceeds 32 limit)."""
376+ self .requires_fullFeature ()
377+ self .setup_mnemonic_allallall ()
378+
379+ tx = bytearray ()
380+ tx .append (0 ) # sig count
381+ tx .append (1 ) # num_required_sigs
382+ tx .append (0 ) # num_readonly_signed
383+ tx .append (1 ) # num_readonly_unsigned
384+ tx .append (33 ) # 33 accounts — over the 32-account parser limit
385+ # Pad 33 fake 32-byte account keys
386+ for _ in range (33 ):
387+ tx .extend (b'\xAA ' * 32 )
388+ tx .extend (b'\xBB ' * 32 ) # blockhash
389+ tx .append (0 ) # 0 instructions
390+
391+ with pytest .raises (CallException ):
392+ self .client .call (messages .SolanaSignTx (
393+ address_n = parse_path ("m/44'/501'/0'/0'" ),
394+ raw_tx = bytes (tx ),
395+ ))
396+
397+ def test_solana_sign_malformed_trailing_bytes (self ):
398+ """Reject a valid transaction that has extra trailing bytes."""
399+ self .requires_fullFeature ()
400+ self .setup_mnemonic_allallall ()
401+
402+ from_pubkey = self ._get_from_pubkey ()
403+ to_pubkey = b'\x22 ' * 32
404+ raw_tx = build_system_transfer_tx (from_pubkey , to_pubkey , 1000000000 )
405+
406+ # Append 10 trailing garbage bytes
407+ raw_tx_bad = raw_tx + b'\xFF ' * 10
408+
409+ with pytest .raises (CallException ):
410+ self .client .call (messages .SolanaSignTx (
411+ address_n = parse_path ("m/44'/501'/0'/0'" ),
412+ raw_tx = raw_tx_bad ,
413+ ))
414+
415+ def test_solana_sign_oversized_raw_tx (self ):
416+ """Reject raw_tx that exceeds the proto max_size (1232 bytes)."""
417+ self .requires_fullFeature ()
418+ self .setup_mnemonic_allallall ()
419+
420+ # 1233 bytes — one byte over the nanopb field limit
421+ raw_tx = b'\x00 ' * 1233
422+
423+ with pytest .raises (CallException ):
424+ self .client .call (messages .SolanaSignTx (
425+ address_n = parse_path ("m/44'/501'/0'/0'" ),
426+ raw_tx = raw_tx ,
427+ ))
428+
429+ # ================================================================
430+ # Multi-instruction tests
431+ # ================================================================
432+
433+ def test_solana_sign_multi_instruction_2x_transfer (self ):
434+ """Two system transfers in a single transaction."""
435+ self .requires_fullFeature ()
436+ self .setup_mnemonic_allallall ()
437+
438+ from_pubkey = self ._get_from_pubkey ()
439+ to_pubkey_1 = b'\x22 ' * 32
440+ to_pubkey_2 = b'\x33 ' * 32
441+ system_program = self .SYSTEM_PROGRAM
442+ blockhash = b'\xBB ' * 32
443+
444+ tx = bytearray ()
445+ tx .append (0 ) # sig count
446+ tx .append (1 ) # num_required_sigs
447+ tx .append (0 ) # num_readonly_signed
448+ tx .append (1 ) # num_readonly_unsigned (system program)
449+
450+ # 4 accounts: from, to_1, to_2, system_program
451+ tx .append (4 )
452+ tx .extend (from_pubkey )
453+ tx .extend (to_pubkey_1 )
454+ tx .extend (to_pubkey_2 )
455+ tx .extend (system_program )
456+
457+ tx .extend (blockhash )
458+
459+ # 2 instructions
460+ tx .append (2 )
461+
462+ # Instruction 1: transfer 1 SOL to to_1
463+ tx .append (3 ) # program_id index (system program)
464+ tx .append (2 ) # 2 account indices
465+ tx .append (0 ) # from
466+ tx .append (1 ) # to_1
467+ instr1 = struct .pack ('<I' , 2 ) + struct .pack ('<Q' , 1000000000 )
468+ tx .append (len (instr1 ))
469+ tx .extend (instr1 )
470+
471+ # Instruction 2: transfer 2 SOL to to_2
472+ tx .append (3 ) # program_id index (system program)
473+ tx .append (2 ) # 2 account indices
474+ tx .append (0 ) # from
475+ tx .append (2 ) # to_2
476+ instr2 = struct .pack ('<I' , 2 ) + struct .pack ('<Q' , 2000000000 )
477+ tx .append (len (instr2 ))
478+ tx .extend (instr2 )
479+
480+ resp = self .client .call (messages .SolanaSignTx (
481+ address_n = parse_path ("m/44'/501'/0'/0'" ),
482+ raw_tx = bytes (tx ),
483+ ))
484+ self .assertEqual (len (resp .signature ), 64 )
485+ self .assertFalse (all (b == 0 for b in resp .signature ))
486+
487+ def test_solana_sign_multi_instruction_transfer_and_memo (self ):
488+ """System transfer + memo instruction in a single transaction."""
489+ self .requires_fullFeature ()
490+ self .setup_mnemonic_allallall ()
491+
492+ from_pubkey = self ._get_from_pubkey ()
493+ to_pubkey = b'\x22 ' * 32
494+ system_program = self .SYSTEM_PROGRAM
495+ memo_program = self .MEMO_PROGRAM
496+ blockhash = b'\xBB ' * 32
497+
498+ tx = bytearray ()
499+ tx .append (0 ) # sig count
500+ tx .append (1 ) # num_required_sigs
501+ tx .append (0 ) # num_readonly_signed
502+ tx .append (2 ) # num_readonly_unsigned (system_program + memo_program)
503+
504+ # 4 accounts: from, to, system_program, memo_program
505+ tx .append (4 )
506+ tx .extend (from_pubkey )
507+ tx .extend (to_pubkey )
508+ tx .extend (system_program )
509+ tx .extend (memo_program )
510+
511+ tx .extend (blockhash )
512+
513+ # 2 instructions
514+ tx .append (2 )
515+
516+ # Instruction 1: system transfer 1 SOL
517+ tx .append (2 ) # program_id index (system program)
518+ tx .append (2 ) # 2 account indices
519+ tx .append (0 ) # from
520+ tx .append (1 ) # to
521+ instr1 = struct .pack ('<I' , 2 ) + struct .pack ('<Q' , 1000000000 )
522+ tx .append (len (instr1 ))
523+ tx .extend (instr1 )
524+
525+ # Instruction 2: memo
526+ tx .append (3 ) # program_id index (memo program)
527+ tx .append (1 ) # 1 account index (signer)
528+ tx .append (0 ) # from (signer)
529+ memo_data = b"payment for services"
530+ tx .append (len (memo_data ))
531+ tx .extend (memo_data )
532+
533+ resp = self .client .call (messages .SolanaSignTx (
534+ address_n = parse_path ("m/44'/501'/0'/0'" ),
535+ raw_tx = bytes (tx ),
536+ ))
537+ self .assertEqual (len (resp .signature ), 64 )
538+ self .assertFalse (all (b == 0 for b in resp .signature ))
539+
540+ # ================================================================
541+ # Token metadata (token_info) tests
542+ # ================================================================
543+
544+ def test_solana_sign_token_transfer_with_metadata (self ):
545+ """SPL Token transfer with SolanaTokenInfo for OLED display of symbol + decimals."""
546+ self .requires_fullFeature ()
547+ self .setup_mnemonic_allallall ()
548+
549+ from_pubkey = self ._get_from_pubkey ()
550+ to_account = b'\x33 ' * 32 # destination token account
551+
552+ # USDC mint (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v)
553+ usdc_mint = bytes ([
554+ 0xc6 , 0xfa , 0x7a , 0xf3 , 0xbe , 0xdb , 0xad , 0x3a ,
555+ 0x3d , 0x65 , 0xf3 , 0x6a , 0xab , 0xc9 , 0x74 , 0x31 ,
556+ 0xb1 , 0xbb , 0xe4 , 0xc2 , 0xd2 , 0xf6 , 0xe0 , 0xe4 ,
557+ 0x7c , 0xa6 , 0x02 , 0x03 , 0x45 , 0x20 , 0x23 , 0x34 ,
558+ ])
559+
560+ # SPL Token Transfer instruction: opcode=3 (u8) + amount (LE u64)
561+ instr_data = bytes ([3 ]) + struct .pack ('<Q' , 1000000 ) # 1.0 USDC (6 decimals)
562+ raw_tx = self ._build_tx (from_pubkey , [to_account ], self .TOKEN_PROGRAM , instr_data )
563+
564+ token_info = messages .SolanaTokenInfo (
565+ mint = usdc_mint ,
566+ symbol = "USDC" ,
567+ decimals = 6 ,
568+ )
569+
570+ resp = self .client .call (messages .SolanaSignTx (
571+ address_n = parse_path ("m/44'/501'/0'/0'" ),
572+ raw_tx = raw_tx ,
573+ token_info = [token_info ],
574+ ))
575+ self .assertEqual (len (resp .signature ), 64 )
576+ self .assertFalse (all (b == 0 for b in resp .signature ))
577+
578+ # ================================================================
579+ # Path edge-case tests
580+ # ================================================================
581+
582+ def test_solana_path_3_elements (self ):
583+ """Non-standard 3-element path m/44'/501'/0' — should still derive and sign."""
584+ self .requires_fullFeature ()
585+ self .setup_mnemonic_allallall ()
586+
587+ # Get address with 3-element path
588+ addr_resp = self .client .call (messages .SolanaGetAddress (
589+ address_n = parse_path ("m/44'/501'/0'" ),
590+ show_display = False ,
591+ ))
592+ ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
593+ n = 0
594+ for c in addr_resp .address :
595+ n = n * 58 + ALPHABET .index (c )
596+ from_pubkey = n .to_bytes (32 , 'big' )
597+ to_pubkey = b'\x22 ' * 32
598+
599+ raw_tx = build_system_transfer_tx (from_pubkey , to_pubkey , 500000000 )
600+
601+ resp = self .client .call (messages .SolanaSignTx (
602+ address_n = parse_path ("m/44'/501'/0'" ),
603+ raw_tx = raw_tx ,
604+ ))
605+ self .assertEqual (len (resp .signature ), 64 )
606+ self .assertFalse (all (b == 0 for b in resp .signature ))
607+
608+ def test_solana_path_wrong_coin_type (self ):
609+ """Path with Ethereum coin type m/44'/60'/0'/0' — firmware should reject or warn."""
610+ self .requires_fullFeature ()
611+ self .setup_mnemonic_allallall ()
612+
613+ # Build a minimal valid-looking tx with a dummy from_pubkey
614+ from_pubkey = b'\x11 ' * 32
615+ to_pubkey = b'\x22 ' * 32
616+ raw_tx = build_system_transfer_tx (from_pubkey , to_pubkey , 100000000 )
617+
618+ with pytest .raises (CallException ):
619+ self .client .call (messages .SolanaSignTx (
620+ address_n = parse_path ("m/44'/60'/0'/0'" ),
621+ raw_tx = raw_tx ,
622+ ))
623+
624+ # ================================================================
625+ # Versioned transaction test
626+ # ================================================================
627+
628+ def test_solana_sign_versioned_v0_opaque (self ):
629+ """Versioned v0 transaction (first byte 0x80) — should require AdvancedMode
630+ for blind/opaque signing since firmware cannot parse address lookup tables."""
631+ self .requires_fullFeature ()
632+ self .setup_mnemonic_allallall ()
633+
634+ from_pubkey = self ._get_from_pubkey ()
635+
636+ # Build a versioned v0 transaction:
637+ # byte 0x80 = version prefix (bit 7 set = versioned, bits 0-6 = version 0)
638+ # Then a minimal legacy-format body after the version byte
639+ to_pubkey = b'\x22 ' * 32
640+ system_program = self .SYSTEM_PROGRAM
641+ blockhash = b'\xBB ' * 32
642+
643+ tx = bytearray ()
644+ tx .append (0x80 ) # version prefix: v0
645+
646+ # Header
647+ tx .append (1 ) # num_required_sigs
648+ tx .append (0 ) # num_readonly_signed
649+ tx .append (1 ) # num_readonly_unsigned
650+
651+ # 3 accounts
652+ tx .append (3 )
653+ tx .extend (from_pubkey )
654+ tx .extend (to_pubkey )
655+ tx .extend (system_program )
656+
657+ # Recent blockhash
658+ tx .extend (blockhash )
659+
660+ # 1 instruction
661+ tx .append (1 )
662+ tx .append (2 ) # program_id index
663+ tx .append (2 ) # 2 account indices
664+ tx .append (0 ) # from
665+ tx .append (1 ) # to
666+ instr_data = struct .pack ('<I' , 2 ) + struct .pack ('<Q' , 1000000000 )
667+ tx .append (len (instr_data ))
668+ tx .extend (instr_data )
669+
670+ # Address table lookups: 0 entries
671+ tx .append (0 )
672+
673+ raw_tx = bytes (tx )
674+
675+ # Without AdvancedMode, versioned tx should be rejected
676+ self .client .apply_policy ('AdvancedMode' , False )
677+ with pytest .raises (CallException ):
678+ self .client .call (messages .SolanaSignTx (
679+ address_n = parse_path ("m/44'/501'/0'/0'" ),
680+ raw_tx = raw_tx ,
681+ ))
682+
683+ # With AdvancedMode, it should succeed (opaque/blind sign)
684+ self .client .apply_policy ('AdvancedMode' , True )
685+ resp = self .client .call (messages .SolanaSignTx (
686+ address_n = parse_path ("m/44'/501'/0'/0'" ),
687+ raw_tx = raw_tx ,
688+ ))
689+ self .assertEqual (len (resp .signature ), 64 )
690+ self .assertFalse (all (b == 0 for b in resp .signature ))
691+ self .client .apply_policy ('AdvancedMode' , False )
692+
693+
357694if __name__ == '__main__' :
358695 unittest .main ()
0 commit comments