@@ -127,6 +127,89 @@ def _op_custom_json(required_auths, required_posting_auths, id_, json_):
127127 return out + _string (id_ ) + _string (json_ )
128128
129129
130+ def _asset (amount , symbol ):
131+ """int64 LE amount + uint8 precision + 7-byte NUL-padded symbol.
132+
133+ Precision is pinned per symbol exactly as firmware's cur_asset requires;
134+ passing the wrong one is what the negative tests below exercise.
135+ """
136+ precision = 6 if symbol == "VESTS" else 3
137+ return (struct .pack ("<q" , amount ) + bytes ([precision ]) +
138+ symbol .encode ("ascii" ).ljust (7 , b"\x00 " ))
139+
140+
141+ def _asset_raw (amount , precision , symbol ):
142+ """Asset with a caller-chosen precision, for malformed-input tests."""
143+ return (struct .pack ("<q" , amount ) + bytes ([precision ]) +
144+ symbol .encode ("ascii" ).ljust (7 , b"\x00 " ))
145+
146+
147+ def _op_transfer_to_vesting (from_ , to , amount ):
148+ return (_varint (3 ) + _string (from_ ) + _string (to ) + _asset (amount , "HIVE" ))
149+
150+
151+ def _op_withdraw_vesting (account , vesting_shares ):
152+ return _varint (4 ) + _string (account ) + _asset (vesting_shares , "VESTS" )
153+
154+
155+ def _op_limit_order_create (owner , orderid , sell , sell_sym , recv , recv_sym ,
156+ fill_or_kill , expiration ):
157+ return (_varint (5 ) + _string (owner ) + struct .pack ("<I" , orderid ) +
158+ _asset (sell , sell_sym ) + _asset (recv , recv_sym ) +
159+ bytes ([1 if fill_or_kill else 0 ]) + struct .pack ("<I" , expiration ))
160+
161+
162+ def _op_limit_order_cancel (owner , orderid ):
163+ return _varint (6 ) + _string (owner ) + struct .pack ("<I" , orderid )
164+
165+
166+ def _op_convert (owner , requestid , amount ):
167+ return (_varint (8 ) + _string (owner ) + struct .pack ("<I" , requestid ) +
168+ _asset (amount , "HBD" ))
169+
170+
171+ def _op_comment_options (author , permlink , max_payout , percent_hbd ,
172+ allow_votes = True , allow_curation = True ,
173+ beneficiaries = None ):
174+ out = (_varint (19 ) + _string (author ) + _string (permlink ) +
175+ _asset (max_payout , "HBD" ) + struct .pack ("<H" , percent_hbd ) +
176+ bytes ([1 if allow_votes else 0 , 1 if allow_curation else 0 ]))
177+ if not beneficiaries :
178+ return out + _varint (0 )
179+ out += _varint (1 ) + _varint (0 ) + _varint (len (beneficiaries ))
180+ for account , weight in beneficiaries :
181+ out += _string (account ) + struct .pack ("<H" , weight )
182+ return out
183+
184+
185+ def _op_transfer_to_savings (from_ , to , amount , symbol , memo = "" ):
186+ return (_varint (32 ) + _string (from_ ) + _string (to ) +
187+ _asset (amount , symbol ) + _string (memo ))
188+
189+
190+ def _op_transfer_from_savings (from_ , request_id , to , amount , symbol , memo = "" ):
191+ return (_varint (33 ) + _string (from_ ) + struct .pack ("<I" , request_id ) +
192+ _string (to ) + _asset (amount , symbol ) + _string (memo ))
193+
194+
195+ def _op_claim_reward_balance (account , hive_amt , hbd_amt , vests_amt ):
196+ return (_varint (39 ) + _string (account ) + _asset (hive_amt , "HIVE" ) +
197+ _asset (hbd_amt , "HBD" ) + _asset (vests_amt , "VESTS" ))
198+
199+
200+ def _op_delegate_vesting_shares (delegator , delegatee , vesting_shares ):
201+ return (_varint (40 ) + _string (delegator ) + _string (delegatee ) +
202+ _asset (vesting_shares , "VESTS" ))
203+
204+
205+ def _op_account_update2 (account , json_metadata , posting_json_metadata ,
206+ authority_present = False ):
207+ return (_varint (43 ) + _string (account ) +
208+ bytes ([1 if authority_present else 0 , 0 , 0 , 0 ]) +
209+ _string (json_metadata ) + _string (posting_json_metadata ) +
210+ _varint (0 ))
211+
212+
130213class _Reader :
131214 """Cursor over the device-emitted Graphene bytes. Matches firmware
132215 serialization exactly (see hive.c append_* helpers)."""
@@ -697,8 +780,12 @@ def test_hive_sign_ops_rejects_excluded_and_unknown_ops(self):
697780 for op_type in (2 , 9 , 10 ):
698781 self ._assert_ops_fails ("dedicated message" ,
699782 _ops_tx ([_varint (op_type )]))
783+ # 49 = recurrent_transfer: a real Hive op deliberately kept out of the
784+ # table. (This previously used op 3, mislabelled "comment_options";
785+ # op 3 is transfer_to_vesting and is now clear-signed, so it no longer
786+ # exercises the unknown-op path.)
700787 self ._assert_ops_fails ("unsupported operation" ,
701- _ops_tx ([_varint (3 )])) # comment_options
788+ _ops_tx ([_varint (49 )]))
702789
703790 def test_hive_sign_ops_rejects_malformed_structure (self ):
704791 """Zero ops, >4 ops, nonzero extensions, trailing bytes, overlong
@@ -715,9 +802,9 @@ def test_hive_sign_ops_rejects_malformed_structure(self):
715802 self ._assert_ops_fails ("trailing bytes" , _ops_tx ([vote ]) + b"\x00 " )
716803 # op_count as an overlong 6-byte varint encoding of 1
717804 head = struct .pack ("<HII" , 12345 , 67890 , 1700000000 )
718- self ._assert_ops_fails ("malformed op count " ,
805+ self ._assert_ops_fails ("malformed operation " ,
719806 head + b"\x81 \x80 \x80 \x80 \x80 \x00 " + vote + b"\x00 " )
720- self ._assert_ops_fails ("weight out of range" ,
807+ self ._assert_ops_fails ("value out of range" ,
721808 _ops_tx ([_op_vote ("kkvoter" , "author" , "permlink" , 10001 )]))
722809
723810 def test_hive_sign_ops_role_fences (self ):
@@ -742,7 +829,7 @@ def test_hive_sign_ops_role_fences(self):
742829 self ._assert_ops_fails ("mixed posting/active" , mixed , path = hive_path (ROLE_ACTIVE ))
743830
744831 both_auths = _ops_tx ([_op_custom_json (["kkadmin" ], ["kkplayer" ], "x-id" , '{"a":1}' )])
745- self ._assert_ops_fails ("mixed active+ posting auths " , both_auths ,
832+ self ._assert_ops_fails ("mixed posting/active " , both_auths ,
746833 path = hive_path (ROLE_ACTIVE ))
747834
748835 def test_hive_sign_ops_rejects_oversize (self ):
@@ -758,6 +845,205 @@ def test_hive_sign_ops_rejects_oversize(self):
758845 hive .sign_operations (self .client , hive_path (ROLE_POSTING ), tx ,
759846 chain_id = HIVE_CHAIN_ID )
760847
848+ # ── Phase-3 op table ─────────────────────────────────────────────────
849+ # Every tx below is built by THIS file's serializer, never by firmware, so
850+ # a parser bug and a serializer bug cannot cancel out.
851+
852+ def _ops_signs_with (self , tx , role ):
853+ """Sign tx with `role` and assert the signature recovers to that key."""
854+ key = hive .get_public_key (self .client , hive_path (role ), show_display = False )
855+ resp = hive .sign_operations (self .client , hive_path (role ), tx ,
856+ chain_id = HIVE_CHAIN_ID )
857+ self .assertEqual (self ._recover_ops_signer (tx , resp .signature ),
858+ key .raw_public_key )
859+
860+ def test_hive_sign_ops_limit_order_create (self ):
861+ """The op that motivated phase 3: a HIVE->HBD internal-market swap.
862+ Active tier, since it moves funds."""
863+ self .requires_firmware ("7.15.0" )
864+ self .requires_message ("HiveSignOperations" )
865+ self .setup_mnemonic_nopin_nopassphrase ()
866+
867+ tx = _ops_tx ([_op_limit_order_create ("kktrader" , 42 , 1500 , "HIVE" ,
868+ 400 , "HBD" , True , 1700003600 )])
869+ self ._ops_signs_with (tx , ROLE_ACTIVE )
870+
871+ def test_hive_sign_ops_limit_order_cancel (self ):
872+ self .requires_firmware ("7.15.0" )
873+ self .requires_message ("HiveSignOperations" )
874+ self .setup_mnemonic_nopin_nopassphrase ()
875+
876+ self ._ops_signs_with (_ops_tx ([_op_limit_order_cancel ("kktrader" , 42 )]),
877+ ROLE_ACTIVE )
878+
879+ def test_hive_sign_ops_active_tier_value_ops (self ):
880+ """The active-tier ops that move or lock value all sign with active."""
881+ self .requires_firmware ("7.15.0" )
882+ self .requires_message ("HiveSignOperations" )
883+ self .setup_mnemonic_nopin_nopassphrase ()
884+
885+ for op in (
886+ _op_transfer_to_vesting ("kkuser" , "kkuser" , 1000 ),
887+ _op_convert ("kkuser" , 7 , 2500 ),
888+ _op_transfer_to_savings ("kkuser" , "kkfriend" , 1500 , "HBD" , "rent" ),
889+ _op_transfer_from_savings ("kkuser" , 7 , "kkfriend" , 1500 , "HIVE" ),
890+ _op_delegate_vesting_shares ("kkuser" , "kkfriend" , 1000000 ),
891+ _op_withdraw_vesting ("kkuser" , 5000000 ),
892+ ):
893+ self ._ops_signs_with (_ops_tx ([op ]), ROLE_ACTIVE )
894+
895+ def test_hive_sign_ops_posting_tier_ops (self ):
896+ """claim_reward_balance is posting tier — claiming is not spending."""
897+ self .requires_firmware ("7.15.0" )
898+ self .requires_message ("HiveSignOperations" )
899+ self .setup_mnemonic_nopin_nopassphrase ()
900+
901+ tx = _ops_tx ([_op_claim_reward_balance ("kkuser" , 1234 , 5678 , 90123456 )])
902+ self ._ops_signs_with (tx , ROLE_POSTING )
903+
904+ def test_hive_sign_ops_zero_amount_semantics (self ):
905+ """Zero means something for these two and nothing for the rest, so the
906+ parser must not apply one blanket rule."""
907+ self .requires_firmware ("7.15.0" )
908+ self .requires_message ("HiveSignOperations" )
909+ self .setup_mnemonic_nopin_nopassphrase ()
910+
911+ # 0 VESTS withdraw_vesting cancels an in-progress power-down.
912+ self ._ops_signs_with (_ops_tx ([_op_withdraw_vesting ("kkuser" , 0 )]),
913+ ROLE_ACTIVE )
914+ # 0 VESTS delegation removes an existing delegation.
915+ self ._ops_signs_with (
916+ _ops_tx ([_op_delegate_vesting_shares ("kkuser" , "kkfriend" , 0 )]),
917+ ROLE_ACTIVE )
918+ # A zero power-up, by contrast, does nothing and is refused.
919+ self ._assert_ops_fails ("amount must be greater than zero" ,
920+ _ops_tx ([_op_transfer_to_vesting ("kkuser" , "kkuser" , 0 )]),
921+ path = hive_path (ROLE_ACTIVE ))
922+ # Nothing to claim.
923+ self ._assert_ops_fails ("no effect" ,
924+ _ops_tx ([_op_claim_reward_balance ("kkuser" , 0 , 0 , 0 )]))
925+
926+ def test_hive_sign_ops_asset_symbol_and_precision_pinned (self ):
927+ """A swapped symbol hides a ~2000x value difference behind an
928+ identical-looking number; a wrong precision moves the decimal point
929+ relative to what the chain applies. Both must be refused."""
930+ self .requires_firmware ("7.15.0" )
931+ self .requires_message ("HiveSignOperations" )
932+ self .setup_mnemonic_nopin_nopassphrase ()
933+ active = hive_path (ROLE_ACTIVE )
934+
935+ # transfer_to_vesting is HIVE-only.
936+ wrong_symbol = (_varint (3 ) + _string ("kkuser" ) + _string ("kkuser" ) +
937+ _asset (1000 , "HBD" ))
938+ self ._assert_ops_fails ("malformed operation" , _ops_tx ([wrong_symbol ]),
939+ path = active )
940+ # Right symbol, wrong precision.
941+ wrong_precision = (_varint (3 ) + _string ("kkuser" ) + _string ("kkuser" ) +
942+ _asset_raw (1000 , 6 , "HIVE" ))
943+ self ._assert_ops_fails ("malformed operation" , _ops_tx ([wrong_precision ]),
944+ path = active )
945+ # Negative int64 would render as an enormous positive amount.
946+ negative = (_varint (3 ) + _string ("kkuser" ) + _string ("kkuser" ) +
947+ _asset_raw (- 1000 , 3 , "HIVE" ))
948+ self ._assert_ops_fails ("malformed operation" , _ops_tx ([negative ]),
949+ path = active )
950+ # An order priced VESTS-for-HBD is not a market that exists.
951+ vests_order = (_varint (5 ) + _string ("kktrader" ) + struct .pack ("<I" , 1 ) +
952+ _asset (100 , "VESTS" ) + _asset (100 , "HBD" ) +
953+ bytes ([0 ]) + struct .pack ("<I" , 1 ))
954+ self ._assert_ops_fails ("malformed operation" , _ops_tx ([vests_order ]),
955+ path = active )
956+ # Same symbol on both sides of an order is a no-op trade that still fills.
957+ same_symbol = _op_limit_order_create ("kktrader" , 1 , 100 , "HIVE" ,
958+ 100 , "HIVE" , False , 1 )
959+ self ._assert_ops_fails ("symbols must differ" , _ops_tx ([same_symbol ]),
960+ path = active )
961+
962+ def test_hive_sign_ops_comment_options_binds_to_its_comment (self ):
963+ """comment_options redirects a post's payout. Detached from its
964+ comment it could retarget a post published earlier that the user is
965+ not reviewing on screen, so firmware requires it to follow one with a
966+ matching author and permlink."""
967+ self .requires_firmware ("7.15.0" )
968+ self .requires_message ("HiveSignOperations" )
969+ self .setup_mnemonic_nopin_nopassphrase ()
970+
971+ comment = _op_comment ("" , "hive-100" , "kkauthor" , "my-post" ,
972+ "Title" , b"Body" , "{}" )
973+ options = _op_comment_options ("kkauthor" , "my-post" , 1000000 , 10000 )
974+
975+ # Standing alone: refused.
976+ self ._assert_ops_fails ("must follow its comment" , _ops_tx ([options ]))
977+ # Following a comment for a DIFFERENT post: refused.
978+ other = _op_comment_options ("kkauthor" , "other-post" , 1000000 , 10000 )
979+ self ._assert_ops_fails ("must follow its comment" ,
980+ _ops_tx ([comment , other ]))
981+ # Correctly paired, with beneficiaries: signs on the posting key.
982+ paired = _ops_tx ([comment , _op_comment_options (
983+ "kkauthor" , "my-post" , 1000000 , 10000 ,
984+ beneficiaries = [("aaron" , 1000 ), ("zoe" , 500 )])])
985+ self ._ops_signs_with (paired , ROLE_POSTING )
986+
987+ def test_hive_sign_ops_comment_options_beneficiary_rules (self ):
988+ """hived requires strictly ascending, unique beneficiaries summing to
989+ <= 100%; signing anything else only wastes a device confirmation."""
990+ self .requires_firmware ("7.15.0" )
991+ self .requires_message ("HiveSignOperations" )
992+ self .setup_mnemonic_nopin_nopassphrase ()
993+
994+ comment = _op_comment ("" , "hive-100" , "kkauthor" , "my-post" ,
995+ "Title" , b"Body" , "{}" )
996+ for bens in ([("zoe" , 500 ), ("aaron" , 1000 )], # unsorted
997+ [("aaron" , 500 ), ("aaron" , 500 )], # duplicate
998+ [("aaron" , 6000 ), ("zoe" , 5000 )]): # > 100%
999+ tx = _ops_tx ([comment , _op_comment_options (
1000+ "kkauthor" , "my-post" , 1000000 , 10000 , beneficiaries = bens )])
1001+ self ._assert_ops_fails ("beneficiaries" , tx )
1002+
1003+ def test_hive_sign_ops_account_update2_rejects_authority_change (self ):
1004+ """account_update2 can rotate account keys. Only the profile-metadata
1005+ form is in the table — the same device-derived-keys invariant that
1006+ keeps ops 9/10 out, applied field-level."""
1007+ self .requires_firmware ("7.15.0" )
1008+ self .requires_message ("HiveSignOperations" )
1009+ self .setup_mnemonic_nopin_nopassphrase ()
1010+
1011+ self ._assert_ops_fails (
1012+ "authority changes" ,
1013+ _ops_tx ([_op_account_update2 ("kkuser" , '{"profile":{}}' , "" ,
1014+ authority_present = True )]),
1015+ path = hive_path (ROLE_ACTIVE ))
1016+
1017+ # json_metadata is an active-key field...
1018+ self ._ops_signs_with (
1019+ _ops_tx ([_op_account_update2 ("kkuser" , '{"profile":{}}' , "" )]),
1020+ ROLE_ACTIVE )
1021+ # ...while a posting-metadata-only profile edit stays posting tier.
1022+ self ._ops_signs_with (
1023+ _ops_tx ([_op_account_update2 ("kkuser" , "" , '{"profile":{}}' )]),
1024+ ROLE_POSTING )
1025+
1026+ def test_hive_sign_ops_truncated_bodies_rejected (self ):
1027+ """The signature covers the whole buffer, so a short read would mean
1028+ signing bytes the device never displayed. Every truncation must be
1029+ refused rather than partially parsed."""
1030+ self .requires_firmware ("7.15.0" )
1031+ self .requires_message ("HiveSignOperations" )
1032+ self .setup_mnemonic_nopin_nopassphrase ()
1033+
1034+ for op in (_op_limit_order_create ("kktrader" , 1 , 100 , "HIVE" , 50 ,
1035+ "HBD" , False , 9 ),
1036+ _op_claim_reward_balance ("kkuser" , 1 , 1 , 1 ),
1037+ _op_transfer_from_savings ("kkuser" , 7 , "kkfriend" , 1500 ,
1038+ "HBD" , "memo" )):
1039+ # One byte short is the boundary case; a deeper cut exercises the
1040+ # length-prefixed string readers.
1041+ for cut in (1 , 5 ):
1042+ if cut >= len (op ):
1043+ continue
1044+ self ._assert_ops_fails (None , _ops_tx ([op [:- cut ]]),
1045+ path = hive_path (ROLE_ACTIVE ))
1046+
7611047 def test_hive_sign_message_rejects_chain_id_prefix (self ):
7621048 """A 'message' that begins with the mainnet chain id would hash to a
7631049 broadcastable TRANSACTION digest (tx digest = SHA256(chain_id || tx)).
0 commit comments