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