Skip to content

Commit 38acf57

Browse files
BitHighlanderclaude
andcommitted
test(hive): device tests for the phase-3 clear-sign op table
Covers the 11 ops added to the firmware clear-sign table: transfer_to_vesting, withdraw_vesting, limit_order_create/cancel, convert, comment_options, transfer_to/from_savings, claim_reward_balance, delegate_vesting_shares, account_update2. Transactions are built by this file's own Graphene serializer (the _op_* helpers), never from firmware-emitted bytes, so a parser bug and a serializer bug cannot cancel out. Beyond happy-path sign-and-recover, the negative cases pin the invariants that actually protect the user: - asset symbol AND precision are pinned per op: a swapped symbol hides a ~2000x value difference behind an identical-looking number, and a wrong precision moves the decimal point relative to what the chain applies - negative int64 amounts are refused (they would render as enormous positive values) - comment_options only binds immediately after a comment with a matching author and permlink; detached it could redirect the payout of a post published earlier that the user is not reviewing on screen - account_update2 refuses any authority field - beneficiaries must be strictly ascending, unique, and sum to <= 100% - zero amounts are refused where meaningless and accepted where meaningful (withdraw_vesting 0 stops a power-down, delegate 0 removes a delegation) - truncated op bodies are refused rather than partially parsed Two fixes to existing tests: - Three assertions matched rejection strings that the firmware has since grouped by cause ("malformed op count", "weight out of range", "mixed active+posting auths"). The text is diagnostic, not contractual — the protection is that the device refuses — so the assertions follow the grouping. - rejects_excluded_and_unknown_ops used op type 3 as its "unknown op", mislabelled in a comment as comment_options. Op 3 is transfer_to_vesting and is now clear-signed, so it no longer reached the unknown-op path. Switched to 49 (recurrent_transfer), which is deliberately out of the table. 38/38 pass against an emulator built from keepkey-firmware feat/hive-clearsign-ops-phase3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8586652 commit 38acf57

1 file changed

Lines changed: 290 additions & 4 deletions

File tree

tests/test_msg_hive.py

Lines changed: 290 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
130213
class _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

Comments
 (0)