Skip to content

Commit c73750c

Browse files
committed
fix(thorchain): expose version-gated send denoms
Forward ThorchainMsgSend.denom on firmware 7.15+, retain the fail-closed RUNE-only path on older firmware, and add offline and device-path coverage. Add regression tests for the reviewed EIP-712 array-order and signed ABI integer fixes.
1 parent e60ce4f commit c73750c

4 files changed

Lines changed: 219 additions & 21 deletions

File tree

keepkeylib/client.py

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,27 +1204,36 @@ def thorchain_sign_tx(
12041204
raise CallException("Thorchain.MsgSend", "Multiple amounts per send msg not supported")
12051205

12061206
denom = msg['value']['amount'][0]['denom']
1207-
# Fail CLOSED on any other denomination, deliberately.
1208-
#
1209-
# ThorchainMsgSend carries a `denom` field, but the firmware
1210-
# this talks to builds its amino sign-doc with the string
1211-
# "rune" HARDCODED (lib/firmware/thorchain.c) -- only 7.15+
1212-
# reads a denom and validates it. nanopb SKIPS unknown fields
1213-
# rather than rejecting them, so forwarding `denom` to older
1214-
# firmware would be silently ignored and the device would sign
1215-
# a rune transfer while the host believed it had sent another
1216-
# asset. Refusing is the only safe answer until the capability
1217-
# can be detected; do not "fix" this by passing denom through.
1218-
if denom != 'rune':
1219-
raise CallException("Thorchain.MsgSend", "Unsupported denomination: " + denom)
1207+
firmware_version = (
1208+
self.features.major_version,
1209+
self.features.minor_version,
1210+
self.features.patch_version,
1211+
)
1212+
supports_denom = firmware_version >= (7, 15, 0)
1213+
1214+
# Older firmware hardcodes "rune" in its amino sign-doc and
1215+
# nanopb skips the unknown denom field. Sending a non-RUNE denom
1216+
# there would therefore make the host and device disagree about
1217+
# what was signed. Preserve the fail-closed legacy behaviour,
1218+
# while exposing the protocol field on firmware that validates,
1219+
# displays and commits it to the signature.
1220+
if denom != 'rune' and not supports_denom:
1221+
raise CallException(
1222+
"Thorchain.MsgSend",
1223+
"Unsupported denomination before firmware 7.15.0: " + denom,
1224+
)
1225+
1226+
send = thorchain_proto.ThorchainMsgSend(
1227+
from_address=msg['value']['from_address'],
1228+
to_address=msg['value']['to_address'],
1229+
amount=int(msg['value']['amount'][0]['amount']),
1230+
address_type=types.SPEND,
1231+
)
1232+
if supports_denom:
1233+
send.denom = denom
12201234

12211235
resp = self.call(thorchain_proto.ThorchainMsgAck(
1222-
send=thorchain_proto.ThorchainMsgSend(
1223-
from_address=msg['value']['from_address'],
1224-
to_address=msg['value']['to_address'],
1225-
amount=int(msg['value']['amount'][0]['amount']),
1226-
address_type=types.SPEND,
1227-
)
1236+
send=send
12281237
))
12291238

12301239
elif msg['type'] == "thorchain/MsgDeposit":

tests/test_clearsign_abi.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import unittest
2+
3+
from keepkeylib.clearsign_abi import encode_static_args
4+
5+
6+
class TestClearsignAbiSignedIntegers(unittest.TestCase):
7+
8+
def test_negative_int8_is_sign_extended(self):
9+
self.assertEqual(encode_static_args(['int8'], [-1]), b'\xff' * 32)
10+
self.assertEqual(
11+
encode_static_args(['int8'], [-128]),
12+
b'\xff' * 31 + b'\x80',
13+
)
14+
15+
def test_int8_bounds_are_enforced(self):
16+
self.assertEqual(
17+
encode_static_args(['int8'], [127]),
18+
b'\x00' * 31 + b'\x7f',
19+
)
20+
for value in (-129, 128):
21+
with self.assertRaises(AssertionError):
22+
encode_static_args(['int8'], [value])
23+
24+
def test_uint8_keeps_unsigned_bounds(self):
25+
self.assertEqual(
26+
encode_static_args(['uint8'], [255]),
27+
b'\x00' * 31 + b'\xff',
28+
)
29+
for value in (-1, 256):
30+
with self.assertRaises(AssertionError):
31+
encode_static_args(['uint8'], [value])
32+
33+
34+
if __name__ == '__main__':
35+
unittest.main()

tests/test_msg_eip712_streaming.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,56 @@
5050
SPEC_MESSAGE_HASH = "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e"
5151

5252

53+
class TestEip712StreamHelpers(unittest.TestCase):
54+
55+
def test_multidimensional_arrays_are_walked_outermost_first(self):
56+
doc = {
57+
'types': {
58+
'EIP712Domain': [],
59+
'Matrix': [{'name': 'values', 'type': 'int16[2][][4]'}],
60+
},
61+
'primaryType': 'Matrix',
62+
'domain': {},
63+
'message': {
64+
'values': [
65+
[[1, 2]],
66+
[[3, 4], [5, 6]],
67+
[[7, 8], [9, 10], [11, 12]],
68+
[[13, 14]],
69+
],
70+
},
71+
}
72+
73+
self.assertEqual(es.resolve_member_path(doc, [1, 0]), ('length', 4))
74+
self.assertEqual(es.resolve_member_path(doc, [1, 0, 2]), ('length', 3))
75+
self.assertEqual(es.resolve_member_path(doc, [1, 0, 2, 1]), ('length', 2))
76+
result = es.resolve_member_path(doc, [1, 0, 2, 1, 0])
77+
self.assertEqual(result[0], 'value')
78+
self.assertEqual(result[2], 9)
79+
80+
def test_innermost_fixed_array_length_is_checked(self):
81+
doc = {
82+
'types': {
83+
'EIP712Domain': [],
84+
'Matrix': [{'name': 'values', 'type': 'int16[2][][4]'}],
85+
},
86+
'primaryType': 'Matrix',
87+
'domain': {},
88+
'message': {
89+
'values': [
90+
[[1, 2]],
91+
[[3, 4]],
92+
[[5]],
93+
[[6, 7]],
94+
],
95+
},
96+
}
97+
98+
with self.assertRaises(es.Eip712Error) as ctx:
99+
es.resolve_member_path(doc, [1, 0, 2, 0])
100+
self.assertIn('declares 2 elements', str(ctx.exception))
101+
102+
53103
class TestMsgEip712Streaming(common.KeepKeyTest):
54104

55105
def _walk(self, doc, max_steps=400):

tests/test_msg_thorchain_signtx.py

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
from binascii import hexlify, unhexlify
66

77
import keepkeylib.messages_pb2 as proto
8+
import keepkeylib.messages_thorchain_pb2 as thorchain_proto
89
import keepkeylib.types_pb2 as proto_types
10+
from keepkeylib.client import CallException, ProtocolMixin
911
from keepkeylib.tools import parse_path
1012
from keepkeylib.signed_metadata import eth_sighash_legacy, keccak256
1113

@@ -28,19 +30,94 @@ def recover_eth_signer(sig_r, sig_s, sig_v, digest, chain_id):
2830

2931
DEFAULT_BIP32_PATH = "m/44h/931h/0h/0/0"
3032

31-
def make_send(from_address, to_address, amount):
33+
def make_send(from_address, to_address, amount, denom='rune'):
3234
return {
3335
'type': 'thorchain/MsgSend',
3436
'value': {
3537
'amount': [{
36-
'denom': 'rune',
38+
'denom': denom,
3739
'amount': str(amount),
3840
}],
3941
'from_address': from_address,
4042
'to_address': to_address,
4143
}
4244
}
4345

46+
47+
class _SessionTransport(object):
48+
def session_begin(self):
49+
pass
50+
51+
def session_end(self):
52+
pass
53+
54+
55+
class _ScriptedThorchainClient(object):
56+
thorchain_sign_tx = ProtocolMixin.thorchain_sign_tx
57+
58+
def __init__(self, version):
59+
self.features = proto.Features(
60+
major_version=version[0],
61+
minor_version=version[1],
62+
patch_version=version[2],
63+
)
64+
self.transport = _SessionTransport()
65+
self.responses = [
66+
thorchain_proto.ThorchainMsgRequest(),
67+
thorchain_proto.ThorchainSignedTx(
68+
public_key=b'\x02' + b'\x11' * 32,
69+
signature=b'\x22' * 64,
70+
),
71+
]
72+
self.sent = []
73+
74+
def call(self, message):
75+
self.sent.append(message)
76+
if not self.responses:
77+
raise AssertionError('unexpected device call: %s' % type(message))
78+
return self.responses.pop(0)
79+
80+
81+
class TestThorchainClientDenom(unittest.TestCase):
82+
ADDRESS_N = [0x8000002C, 0x800003A3, 0x80000000, 0, 0]
83+
FROM = 'thor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8'
84+
TO = 'thor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy'
85+
86+
def _sign(self, client, denom):
87+
return client.thorchain_sign_tx(
88+
address_n=self.ADDRESS_N,
89+
account_number=92,
90+
chain_id='thorchain',
91+
fee=3000,
92+
gas=200000,
93+
msgs=[make_send(self.FROM, self.TO, 10000, denom=denom)],
94+
memo='client denom test',
95+
sequence=3,
96+
testnet=False,
97+
)
98+
99+
def test_non_rune_denom_is_forwarded_on_7_15(self):
100+
client = _ScriptedThorchainClient((7, 15, 0))
101+
response = self._sign(client, 'btc/btc')
102+
103+
self.assertIsInstance(response, thorchain_proto.ThorchainSignedTx)
104+
self.assertEqual(client.sent[1].send.denom, 'btc/btc')
105+
106+
def test_non_rune_denom_is_rejected_before_7_15(self):
107+
client = _ScriptedThorchainClient((7, 14, 2))
108+
109+
with self.assertRaises(CallException) as ctx:
110+
self._sign(client, 'btc/btc')
111+
112+
self.assertIn('before firmware 7.15.0', str(ctx.exception))
113+
self.assertEqual(len(client.sent), 1)
114+
115+
def test_legacy_rune_does_not_send_unknown_field(self):
116+
client = _ScriptedThorchainClient((7, 14, 2))
117+
self._sign(client, 'rune')
118+
119+
self.assertFalse(client.sent[1].send.HasField('denom'))
120+
44121
class TestMsgThorChainSignTx(common.KeepKeyTest):
45122

46123
def test_thorchain_sign_tx(self):
@@ -66,6 +143,33 @@ def test_thorchain_sign_tx(self):
66143
self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3")
67144
return
68145

146+
def test_thorchain_non_rune_denom_changes_signature(self):
147+
"""The public helper forwards denom and firmware commits it to sign-doc."""
148+
self.requires_fullFeature()
149+
self.requires_firmware("7.15.0")
150+
self.setup_mnemonic_nopin_nopassphrase()
151+
address_n = parse_path(DEFAULT_BIP32_PATH)
152+
from_address = self.client.thorchain_get_address(address_n)
153+
to_address = "thor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy"
154+
155+
def sign(denom):
156+
return self.client.thorchain_sign_tx(
157+
address_n=address_n,
158+
account_number=92,
159+
chain_id="thorchain",
160+
fee=3000,
161+
gas=200000,
162+
msgs=[make_send(from_address, to_address, 10000, denom=denom)],
163+
memo="denom binding",
164+
sequence=3,
165+
testnet=False,
166+
)
167+
168+
rune = sign('rune')
169+
btc = sign('btc/btc')
170+
self.assertEqual(hexlify(rune.public_key), hexlify(btc.public_key))
171+
self.assertNotEqual(hexlify(rune.signature), hexlify(btc.signature))
172+
69173
def test_sign_btc_eth_swap(self):
70174
self.requires_fullFeature()
71175
self.requires_firmware("7.0.2")

0 commit comments

Comments
 (0)