Skip to content

Commit 297cba3

Browse files
committed
feat(7.14.2): XRP THORChain memo support + EVM depositWithExpiry recognition
- Bump device-protocol submodule to 8f80bcd (adds memo field to RippleSignTx) - Update messages_ripple_pb2.py with memo field (field 7, optional string) compatible with protobuf==3.20.3 (old-format serialized_pb descriptor) - Add test_sign_with_thorchain_memo in test_msg_ripple_sign_tx.py: verifies serialized XRPL tx ends with canonical Memos array binary (F9 EA 7D <len> <memo> E1 F1), requires firmware 7.14.2 - Add test_msg_ethereum_thorchain_deposit.py: covers legacy deposit() 0x1fece7b4 selector, new depositWithExpiry() 0x44bc937b selector (requires 7.14.2, no AdvancedMode), and verifies non-THORChain addresses are still blocked without AdvancedMode
1 parent 38b57f7 commit 297cba3

3 files changed

Lines changed: 206 additions & 6 deletions

File tree

keepkeylib/messages_ripple_pb2.py

Lines changed: 13 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# This file is part of the KeepKey project.
2+
#
3+
# Copyright (C) 2026 KeepKey
4+
#
5+
# This library is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU Lesser General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# Test coverage for THORChain EVM depositWithExpiry() selector recognition.
11+
# The legacy deposit() selector (0x1fece7b4) was already handled; firmware
12+
# 7.14.2 adds recognition of the modern depositWithExpiry() selector (0x44bc937b).
13+
14+
import unittest
15+
import common
16+
import binascii
17+
18+
import keepkeylib.messages_pb2 as proto
19+
from keepkeylib.tools import parse_path
20+
21+
22+
THOR_ROUTER = "d37bbe5744d730a1d98d8dc97c42f0ca46ad7146" # ETH THORChain router
23+
ETH_NATIVE = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" # sentinel for native ETH
24+
25+
26+
def _build_deposit_calldata(memo):
27+
"""Build deposit(address,address,uint256,string) calldata (legacy selector)."""
28+
selector = bytes.fromhex("1fece7b4")
29+
vault = bytes(12) + bytes.fromhex(THOR_ROUTER)
30+
asset = bytes(12) + bytes.fromhex(ETH_NATIVE)
31+
amount = (500000000000000000).to_bytes(32, "big") # 0.5 ETH
32+
memo_offset = (4 * 32).to_bytes(32, "big") # offset = 128
33+
memo_bytes = memo.encode("ascii")
34+
memo_len = len(memo_bytes).to_bytes(32, "big")
35+
pad = ((len(memo_bytes) + 31) // 32) * 32
36+
memo_data = memo_bytes + bytes(pad - len(memo_bytes))
37+
return selector + vault + asset + amount + memo_offset + memo_len + memo_data
38+
39+
40+
def _build_deposit_with_expiry_calldata(memo, expiry=9999999999):
41+
"""Build depositWithExpiry(address,address,uint256,string,uint256) calldata."""
42+
selector = bytes.fromhex("44bc937b")
43+
vault = bytes(12) + bytes.fromhex(THOR_ROUTER)
44+
asset = bytes(12) + bytes.fromhex(ETH_NATIVE)
45+
amount = (500000000000000000).to_bytes(32, "big") # 0.5 ETH
46+
memo_offset = (5 * 32).to_bytes(32, "big") # offset = 160 (after expiry)
47+
expiry_b = expiry.to_bytes(32, "big")
48+
memo_bytes = memo.encode("ascii")
49+
memo_len = len(memo_bytes).to_bytes(32, "big")
50+
pad = ((len(memo_bytes) + 31) // 32) * 32
51+
memo_data = memo_bytes + bytes(pad - len(memo_bytes))
52+
return selector + vault + asset + amount + memo_offset + expiry_b + memo_len + memo_data
53+
54+
55+
class TestMsgEthereumThorchainDeposit(common.KeepKeyTest):
56+
57+
def test_deposit_legacy_selector(self):
58+
"""Existing deposit() selector (0x1fece7b4) is recognized without AdvancedMode."""
59+
self.requires_fullFeature()
60+
self.requires_firmware("7.5.0")
61+
self.setup_mnemonic_allallall()
62+
63+
memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0"
64+
data = _build_deposit_calldata(memo)
65+
66+
sig_v, sig_r, sig_s = self.client.ethereum_sign_tx(
67+
n=parse_path("m/44'/60'/0'/0/0"),
68+
nonce=1,
69+
gas_price=50000000000,
70+
gas_limit=300000,
71+
to=binascii.unhexlify(THOR_ROUTER),
72+
value=500000000000000000,
73+
chain_id=1,
74+
data=data,
75+
)
76+
self.assertIn(sig_v, [27, 28])
77+
self.assertEqual(len(sig_r), 32)
78+
self.assertEqual(len(sig_s), 32)
79+
80+
def test_deposit_with_expiry_selector(self):
81+
"""Modern depositWithExpiry() selector (0x44bc937b) is recognized without AdvancedMode.
82+
83+
Before 7.14.2 the firmware only matched the legacy 0x1fece7b4 selector.
84+
All modern THORChain routers use depositWithExpiry. Without this fix the
85+
device would fall through to the blind-sign gate and refuse to sign (or
86+
require AdvancedMode), breaking every EVM->THORChain swap.
87+
"""
88+
self.requires_fullFeature()
89+
self.requires_firmware("7.14.2")
90+
self.setup_mnemonic_allallall()
91+
92+
memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0"
93+
data = _build_deposit_with_expiry_calldata(memo)
94+
95+
# AdvancedMode is intentionally OFF — THORChain txs must sign without it.
96+
sig_v, sig_r, sig_s = self.client.ethereum_sign_tx(
97+
n=parse_path("m/44'/60'/0'/0/0"),
98+
nonce=2,
99+
gas_price=50000000000,
100+
gas_limit=300000,
101+
to=binascii.unhexlify(THOR_ROUTER),
102+
value=500000000000000000,
103+
chain_id=1,
104+
data=data,
105+
)
106+
self.assertIn(sig_v, [27, 28])
107+
self.assertEqual(len(sig_r), 32)
108+
self.assertEqual(len(sig_s), 32)
109+
110+
def test_deposit_with_expiry_non_thor_address_blind_sign_blocked(self):
111+
"""depositWithExpiry to a non-THORChain address must not be auto-approved.
112+
113+
The firmware only clears the blind-sign gate when msg->has_to && the
114+
deposit selector matches. Sending to an arbitrary address must still
115+
require AdvancedMode so unrelated contracts can't exploit the selector.
116+
"""
117+
self.requires_fullFeature()
118+
self.requires_firmware("7.14.2")
119+
self.setup_mnemonic_allallall()
120+
121+
memo = "malicious memo"
122+
data = _build_deposit_with_expiry_calldata(memo)
123+
124+
from keepkeylib.client import CallException
125+
import keepkeylib.types_pb2 as types
126+
127+
# No AdvancedMode, random contract address — should be rejected
128+
with self.assertRaises((CallException, Exception)):
129+
self.client.ethereum_sign_tx(
130+
n=parse_path("m/44'/60'/0'/0/0"),
131+
nonce=3,
132+
gas_price=50000000000,
133+
gas_limit=300000,
134+
to=binascii.unhexlify("1234567890123456789012345678901234567890"),
135+
value=0,
136+
chain_id=1,
137+
data=data,
138+
)
139+
140+
141+
if __name__ == "__main__":
142+
unittest.main()

tests/test_msg_ripple_sign_tx.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,57 @@ def test_sign(self):
100100
)
101101

102102

103+
def test_sign_with_thorchain_memo(self):
104+
self.requires_fullFeature()
105+
self.requires_firmware("7.14.2")
106+
107+
self.setup_mnemonic_allallall()
108+
109+
memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0"
110+
msg = messages.RippleSignTx(
111+
address_n=parse_path("m/44'/144'/0'/0/0"),
112+
payment=messages.RipplePayment(
113+
amount=100000000,
114+
destination="rBKz5MC2iXdoS3XgnNSYmF69K1Yo4NS3Ws"
115+
),
116+
flags=0x80000000,
117+
fee=100000,
118+
sequence=25,
119+
memo=memo
120+
)
121+
resp = self.client.call(msg)
122+
123+
# Verify the XRPL Memos array is appended to the serialized tx.
124+
# Format: 0xF9 (STArray[9]) 0xEA (STObject[10]) 0x7D (MemoData VL[13])
125+
# <varint len> <UTF-8 memo bytes> 0xE1 (end object) 0xF1 (end array)
126+
memo_bytes = memo.encode('ascii')
127+
expected_tail = (
128+
bytes([0xF9, 0xEA, 0x7D, len(memo_bytes)]) +
129+
memo_bytes +
130+
bytes([0xE1, 0xF1])
131+
)
132+
self.assertTrue(
133+
resp.serialized_tx.endswith(expected_tail),
134+
"serialized_tx must end with XRPL Memos array containing THORChain routing memo"
135+
)
136+
137+
# A plain send without memo must not contain the Memos marker
138+
msg_no_memo = messages.RippleSignTx(
139+
address_n=parse_path("m/44'/144'/0'/0/0"),
140+
payment=messages.RipplePayment(
141+
amount=100000000,
142+
destination="rBKz5MC2iXdoS3XgnNSYmF69K1Yo4NS3Ws"
143+
),
144+
flags=0x80000000,
145+
fee=100000,
146+
sequence=26
147+
)
148+
resp2 = self.client.call(msg_no_memo)
149+
self.assertFalse(
150+
b'\xf9' in resp2.serialized_tx,
151+
"plain send must not contain Memos array (0xF9 marker)"
152+
)
153+
103154
def test_ripple_sign_invalid_fee(self):
104155
self.requires_fullFeature()
105156
self.requires_firmware("6.4.0")

0 commit comments

Comments
 (0)