Skip to content

Commit 7e3a15d

Browse files
Merge pull request #775 from LedgerHQ/feat/apa/web3_v7
Update client to use Web3.py v7
2 parents be53fee + 941aae8 commit 7e3a15d

File tree

7 files changed

+119
-105
lines changed

7 files changed

+119
-105
lines changed

client/pyproject.toml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,20 @@ readme = { file = "README.md", content-type = "text/markdown" }
1616
# license = { file = "LICENSE" }
1717
classifiers = [
1818
"License :: OSI Approved :: Apache Software License",
19-
"Programming Language :: Python :: 3.7",
20-
"Programming Language :: Python :: 3.8",
2119
"Programming Language :: Python :: 3.9",
2220
"Programming Language :: Python :: 3.10",
21+
"Programming Language :: Python :: 3.11",
22+
"Programming Language :: Python :: 3.12",
23+
"Programming Language :: Python :: 3.13",
2324
"Operating System :: POSIX :: Linux",
2425
"Operating System :: Microsoft :: Windows",
2526
"Operating System :: MacOS :: MacOS X",
2627
]
2728
dynamic = [ "version" ]
28-
requires-python = ">=3.7"
29+
requires-python = ">=3.9"
2930
dependencies = [
3031
"ragger[speculos]",
31-
"web3~=6.0",
32+
"web3~=7.0",
3233
]
3334

3435
[tools.setuptools]

client/src/ledger_app_clients/ethereum/client.py

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import struct
22
from enum import IntEnum
3-
from typing import Optional, Tuple
3+
from typing import Optional
44
from hashlib import sha256
55
import rlp
66
from web3 import Web3
@@ -213,13 +213,10 @@ def eip712_filtering_trusted_name(self,
213213
def eip712_filtering_raw(self, name: str, sig: bytes, discarded: bool):
214214
return self._exchange_async(self._cmd_builder.eip712_filtering_raw(name, sig, discarded))
215215

216-
def serialize_tx(self, tx_params: dict, tx_raw: Optional[bytes] = None) -> Tuple[bytes, bytes]:
216+
def serialize_tx(self, tx_params: dict) -> tuple[bytes, bytes]:
217217
"""Computes the serialized TX and its hash"""
218218

219-
if tx_raw is not None:
220-
tx = tx_raw
221-
else:
222-
tx = Web3().eth.account.create().sign_transaction(tx_params).rawTransaction
219+
tx = Web3().eth.account.create().sign_transaction(tx_params).raw_transaction
223220
prefix = bytes()
224221
suffix = []
225222
if tx[0] in [0x01, 0x02, 0x04]:
@@ -228,20 +225,16 @@ def serialize_tx(self, tx_params: dict, tx_raw: Optional[bytes] = None) -> Tuple
228225
else: # legacy
229226
if "chainId" in tx_params:
230227
suffix = [int(tx_params["chainId"]), bytes(), bytes()]
231-
if tx_raw is None:
232-
decoded_tx = rlp.decode(tx)[:-3] # remove already computed signature
233-
else:
234-
decoded_tx = rlp.decode(tx)
228+
decoded_tx = rlp.decode(tx)[:-3] # remove already computed signature
235229
encoded_tx = prefix + rlp.encode(decoded_tx + suffix)
236230
tx_hash = keccak(encoded_tx)
237231
return encoded_tx, tx_hash
238232

239233
def sign(self,
240234
bip32_path: str,
241235
tx_params: dict,
242-
tx_raw: Optional[bytes] = None, # Introduced for 7702 until web3.py supports authorization lists
243236
mode: SignMode = SignMode.BASIC):
244-
tx, _ = self.serialize_tx(tx_params, tx_raw)
237+
tx, _ = self.serialize_tx(tx_params)
245238
chunks = self._cmd_builder.sign(bip32_path, tx, mode)
246239
for chunk in chunks[:-1]:
247240
self._exchange(chunk)
@@ -338,7 +331,7 @@ def provide_trusted_name_v2(self,
338331
chain_id: int,
339332
nft_id: Optional[int] = None,
340333
challenge: Optional[int] = None,
341-
not_valid_after: Optional[Tuple[int]] = None) -> RAPDU:
334+
not_valid_after: Optional[tuple[int, int, int]] = None) -> RAPDU:
342335
payload = format_tlv(FieldTag.STRUCT_VERSION, 2)
343336
payload += format_tlv(FieldTag.TRUSTED_NAME, name)
344337
payload += format_tlv(FieldTag.ADDRESS, addr)

client/src/ledger_app_clients/ethereum/response_parser.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
def signature(data: bytes) -> tuple[bytes, bytes, bytes]:
1+
def signature(data: bytes) -> tuple[int, int, int]:
22
assert len(data) == (1 + 32 + 32)
33

4-
v = data[0:1]
4+
v = int.from_bytes(data[0:1], "big")
55
data = data[1:]
6-
r = data[0:32]
6+
r = int.from_bytes(data[0:32], "big")
77
data = data[32:]
8-
s = data[0:32]
8+
s = int.from_bytes(data[0:32], "big")
99

1010
return v, r, s
1111

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,27 @@
1-
from typing import Optional
21
from eth_account import Account
32
from eth_account.messages import encode_defunct, encode_typed_data
3+
from eth_account.datastructures import SignedSetCodeAuthorization
4+
from eth_account.typed_transactions.set_code_transaction import Authorization
5+
from eth_keys.datatypes import Signature
46
import rlp
57

68

7-
# eth_account requires it for some reason
8-
def normalize_vrs(vrs: tuple) -> tuple:
9-
vrs_l = []
10-
for elem in vrs:
11-
vrs_l.append(elem.lstrip(b'\x00'))
12-
return tuple(vrs_l)
13-
14-
159
def get_selector_from_data(data: str) -> bytes:
1610
raw_data = bytes.fromhex(data[2:])
1711
return raw_data[:4]
1812

1913

20-
def recover_message(msg, vrs: tuple) -> bytes:
14+
def recover_message(msg, vrs: tuple[int, int, int]) -> bytes:
2115
if isinstance(msg, dict): # EIP-712
2216
smsg = encode_typed_data(full_message=msg)
2317
else: # EIP-191
2418
smsg = encode_defunct(primitive=msg)
25-
addr = Account.recover_message(smsg, normalize_vrs(vrs))
19+
addr = Account.recover_message(smsg, vrs)
2620
return bytes.fromhex(addr[2:])
2721

2822

29-
def recover_transaction(tx_params, vrs: tuple, raw_tx_param: Optional[bytes] = None) -> bytes:
30-
if raw_tx_param is None:
31-
raw_tx = Account.create().sign_transaction(tx_params).rawTransaction
32-
else:
33-
raw_tx = raw_tx_param
23+
def recover_transaction(tx_params, vrs: tuple[int, int, int]) -> bytes:
24+
raw_tx = Account.create().sign_transaction(tx_params).raw_transaction
3425
prefix = bytes()
3526
if raw_tx[0] in [0x01, 0x02, 0x04]:
3627
prefix = raw_tx[:1]
@@ -44,7 +35,7 @@ def recover_transaction(tx_params, vrs: tuple, raw_tx_param: Optional[bytes] = N
4435
trunc_chain_id >>= 8
4536

4637
trunc_target = trunc_chain_id * 2 + 35
47-
trunc_v = int.from_bytes(vrs[0], "big")
38+
trunc_v = vrs[0]
4839

4940
if (trunc_target & 0xff) == trunc_v:
5041
parity = 0
@@ -56,15 +47,35 @@ def recover_transaction(tx_params, vrs: tuple, raw_tx_param: Optional[bytes] = N
5647

5748
# https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md
5849
full_v = parity + tx_params["chainId"] * 2 + 35
59-
# 9 bytes would be big enough even for the biggest chain ID
60-
vrs = (int(full_v).to_bytes(9, "big"), vrs[1], vrs[2])
50+
vrs = (full_v, vrs[1], vrs[2])
6151
else:
6252
# Pre EIP-155 TX
6353
assert False
6454
decoded = rlp.decode(raw_tx)
65-
if raw_tx_param is None:
66-
reencoded = rlp.encode(decoded[:-3] + list(normalize_vrs(vrs)))
67-
else:
68-
reencoded = rlp.encode(decoded + list(normalize_vrs(vrs)))
55+
reencoded = rlp.encode(decoded[:-3] + list(vrs))
6956
addr = Account.recover_transaction(prefix + reencoded)
7057
return bytes.fromhex(addr[2:])
58+
59+
60+
# Code inspired by :
61+
# https://github.com/ethereum/eth-account/blob/a1ba20c9a112d3534ac3296f21f51e2f5127bf9b/eth_account/account.py#L1057
62+
def get_authorization_obj(chain_id: int,
63+
nonce: int,
64+
address: bytes,
65+
vrs: tuple[int, int, int]) -> SignedSetCodeAuthorization:
66+
unsigned_authorization = Authorization(chain_id, address, nonce)
67+
sig = Signature(vrs=vrs)
68+
return SignedSetCodeAuthorization(
69+
chain_id=chain_id,
70+
address=address,
71+
nonce=nonce,
72+
y_parity=sig.v,
73+
r=sig.r,
74+
s=sig.s,
75+
signature=sig,
76+
authorization_hash=unsigned_authorization.hash(),
77+
)
78+
79+
80+
def recover_authorization(chain_id: int, nonce: int, address: bytes, vrs: tuple[int, int, int]) -> bytes:
81+
return get_authorization_obj(chain_id, nonce, address, vrs).authority

tests/ragger/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
pytest
22
ecdsa
3-
web3~=6.0
3+
web3~=7.0
44
ragger[speculos]
55
py_ecc

tests/ragger/test_eip7702.py

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22

3+
from typing import Optional
34
from ragger.error import ExceptionRAPDU
45
from ragger.backend import BackendInterface
56
from ragger.firmware import Firmware
@@ -11,6 +12,8 @@
1112
import client.response_parser as ResponseParser
1213
from client.tx_auth_7702 import TxAuth7702
1314

15+
from client.utils import recover_authorization
16+
1417
BIP32_PATH = "m/44'/60'/0'/0/0"
1518
TEST_ADDRESS_0 = bytes.fromhex("00" * 20)
1619
TEST_ADDRESS_1 = bytes.fromhex("01" * 20)
@@ -24,6 +27,8 @@
2427
NONCE = 1337
2528
NONCE_MAX = 0xFFFFFFFFFFFFFFFF
2629

30+
DEVICE_ADDR: Optional[bytes] = None
31+
2732
# Test vectors computed with
2833
# cast wallet sign-auth $ADDRESS --mnemonic $MNEMONIC --mnemonic-derivation-path "m/44'/60'/0'/0/0" --nonce $NONCE --chain $CHAINID
2934
# Decoded by https://codechain-io.github.io/rlp-debugger/
@@ -35,23 +40,25 @@ def common(firmware: Firmware,
3540
test_name: str,
3641
delegate: bytes,
3742
nonce: int,
38-
chain_id: int,
39-
v: bytes = None,
40-
r: bytes = None,
41-
s: bytes = None):
43+
chain_id: int):
4244

45+
global DEVICE_ADDR
4346
app_client = EthAppClient(backend)
4447

4548
if firmware.is_nano:
4649
end_text = "Accept"
4750
else:
4851
end_text = ".*Sign.*"
4952

53+
if DEVICE_ADDR is None:
54+
with app_client.get_public_addr(bip32_path=BIP32_PATH, display=False):
55+
pass
56+
_, DEVICE_ADDR, _ = ResponseParser.pk_addr(app_client.response().data)
5057
auth_params = TxAuth7702(delegate, nonce, chain_id)
5158
with app_client.sign_eip7702_authorization(BIP32_PATH, auth_params):
5259
scenario.review_approve(test_name=test_name, custom_screen_text=end_text)
5360
vrs = ResponseParser.signature(app_client.response().data)
54-
assert vrs == (v, r, s)
61+
assert recover_authorization(chain_id, nonce, delegate, vrs) == DEVICE_ADDR
5562

5663

5764
def common_rejected(firmware: Firmware,
@@ -95,10 +102,7 @@ def test_eip7702_in_whitelist(firmware: Firmware,
95102
test_name,
96103
TEST_ADDRESS_1,
97104
NONCE,
98-
CHAIN_ID_1,
99-
bytes.fromhex("00"),
100-
bytes.fromhex("f82e 50a7 55fa 989f 4bb9 b36b 15af b442 4ce9 cda6 9752 fd17 a7eb 1473 7d96 3e62"),
101-
bytes.fromhex("07c9 c91d 6140 b45e a52f 29de 7a5e ffb9 dd34 0607 a26e 225c 4027 8e91 c405 4492"))
105+
CHAIN_ID_1)
102106

103107

104108
def test_eip7702_in_whitelist_all_chain_whitelisted(firmware: Firmware,
@@ -114,10 +118,7 @@ def test_eip7702_in_whitelist_all_chain_whitelisted(firmware: Firmware,
114118
test_name,
115119
TEST_ADDRESS_0,
116120
NONCE,
117-
CHAIN_ID_2,
118-
bytes.fromhex("00"),
119-
bytes.fromhex("0378 f7ac 482e c728 b65d 19d0 3943 bbb3 fe73 07c7 2c64 6e7d 2d0c 11be e81e b2b9"),
120-
bytes.fromhex("3322 66ec 3ef9 96bf 835c 50a8 3300 6b4c 8039 8d59 7d0e 6846 19db 4d51 a384 a38d"))
121+
CHAIN_ID_2)
121122

122123

123124
def test_eip7702_in_whitelist_all_chain_param(firmware: Firmware,
@@ -133,10 +134,7 @@ def test_eip7702_in_whitelist_all_chain_param(firmware: Firmware,
133134
test_name,
134135
TEST_ADDRESS_2,
135136
NONCE,
136-
CHAIN_ID_0,
137-
bytes.fromhex("01"),
138-
bytes.fromhex("a24f 35ca fc6b 408c e325 39d4 bd89 a67e dd4d 6303 fc67 6dfd df93 b984 05b7 ee5e"),
139-
bytes.fromhex("1594 56ba be65 6692 959c a3d8 29ca 269e 8f82 387c 91e4 0a33 633d 190d da7a 3c5c"))
137+
CHAIN_ID_0)
140138

141139

142140
def test_eip7702_in_whitelist_max(firmware: Firmware,
@@ -152,10 +150,7 @@ def test_eip7702_in_whitelist_max(firmware: Firmware,
152150
test_name,
153151
TEST_ADDRESS_MAX,
154152
NONCE_MAX,
155-
CHAIN_ID_MAX,
156-
bytes.fromhex("00"),
157-
bytes.fromhex("a07c 6808 9449 8c21 e80f ebb0 11ae 5d62 ede6 645d 77d7 c902 db06 5d5a 082d d220"),
158-
bytes.fromhex("6b5c 6222 5cf6 5a62 40c2 583d acc1 641c 8d69 2ed3 bd00 473c c3e2 8fe1 a4f5 2294"))
153+
CHAIN_ID_MAX)
159154

160155

161156
def test_eip7702_in_whitelist_wrong_chain(firmware: Firmware,

0 commit comments

Comments
 (0)