-
Notifications
You must be signed in to change notification settings - Fork 9
/
web3.py
236 lines (174 loc) · 8.01 KB
/
web3.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
"""Web3.py middleware for Ledger devices."""
from typing import Any, cast
# Some of the following imports utilize web3.py deps that are not deps of
# ledgereth.
from eth_account.messages import encode_typed_data
from eth_utils.hexadecimal import decode_hex
from web3.middleware import Web3Middleware
from web3.types import MakeRequestFn, RPCEndpoint, RPCResponse
from ledgereth.accounts import find_account, get_accounts
from ledgereth.messages import sign_message, sign_typed_data_draft
from ledgereth.transactions import create_transaction
from ledgereth.utils import decode_web3_access_list
"""
ACCOUNT DERIVATION ISSUES
Derivation path debate is ever ongoing. Ledger Live app uses opton #3 here.
The old chrome app used #4.
1) 44'/60'/0'/0/x
2) 44'/60'/0'/x/0
3) 44'/60'/x'/0/0
4) 44'/60'/0'/x
The Ledger Live account enumeration algo appears to be:
1) Try legacy account X (if no balance, GOTO 3)
2) X+1 and GOTO 1
3) Try new-derivation Y (if no balance, RETURN)
4) Y+1 and GOTO 3
Since this library is trying not to resort to JSON-RPC calls, this algorithm is
not usable, so it's either or, and it currently defaults to the newer
derivation.
To use legacy derivation, set the environment variable LEDGER_LEGACY_ACCOUNTS
Ref (cannot find an authoritative source):
https://github.com/ethereum/EIPs/issues/84#issuecomment-292324521
"""
class AccountNotFoundError(ValueError):
"""An account with the given address was not found on the Ledger device.
.. warning:: This might raise if the account is not found within
``MAX_ACCOUNTS_FETCH`` iterations on the derivation path.
"""
pass
def _make_response(result: Any) -> RPCResponse:
return {
"jsonrpc": "2.0",
"id": 1337,
"result": result,
}
class LedgerSignerMiddleware(Web3Middleware):
"""Web3.py middleware to leverage the Ledger device as a signer.
It will automatically intercept the relevant JSON-RPC calls and respond with data
from your Ledger device.
:Intercepted JSON-RPC methods:
- ``eth_sendTransaction`` (``web3.eth.send_transaction()``)
- ``eth_accounts`` (``web3.eth.accounts``)
- ``eth_sign`` (``web3.eth.sign()``)
- ``eth_signTypedData`` (``web3.eth.sign_typed_data()``)
:Example:
.. code:: python
>>> from web3.auto import w3
>>> from ledgereth.web3 import LedgerSignerMiddleware
>>> w3.middleware_onion.add(LedgerSignerMiddleware)
>>> w3.eth.accounts
['0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', '0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650', '0x98e503f35D0a019cB0a251aD243a4cCFCF371F46']
""" # noqa: E501
_dongle = None
def wrap_make_request(self, make_request: MakeRequestFn):
"""Intercept some JSON-RPC requests and forward them to the Ledger device."""
def middleware(method: RPCEndpoint, params: Any) -> RPCResponse:
if method == "eth_sendTransaction":
return self._handle_eth_send_transaction(params, make_request)
elif method == "eth_accounts":
return self._handle_eth_accounts(params)
elif method == "eth_sign":
return self._handle_eth_sign(params)
elif method == "eth_signTypedData":
return self._handle_eth_sign_typed_data(params)
# Send on to the next middleware(s)
return make_request(method, params)
return middleware
def _handle_eth_accounts(self, _: Any) -> RPCResponse:
"""Handler for eth_accounts RPC calls."""
return _make_response(
list(map(lambda a: a.address, get_accounts(dongle=self._dongle)))
)
def _handle_eth_send_transaction(
self, params: Any, make_request: MakeRequestFn
) -> RPCResponse:
"""Handler for eth_sendTransaction RPC calls."""
new_params = []
for tx_obj in params:
sender_address = tx_obj.get("from")
nonce = tx_obj.get("nonce")
gas = tx_obj.get("gas")
gas_price = tx_obj.get("gasPrice")
max_fee_per_gas = tx_obj.get("maxFeePerGas")
max_priority_fee_per_gas = tx_obj.get("maxPriorityFeePerGas")
value = tx_obj.get("value", "0x00")
access_list = None
if not sender_address:
# TODO: Should this use a default?
raise ValueError('"from" field not provided')
if not gas:
# TODO: What's the default web3.py behavior for this?
raise ValueError('"gas" field not provided')
if not gas_price and not max_fee_per_gas:
raise ValueError('"gasPrice" or "maxFeePerGas" field not provided')
sender_account = find_account(sender_address, dongle=self._dongle)
if not sender_account:
raise AccountNotFoundError(f"Account {sender_address} not found")
if nonce is None:
nonce = self._w3.eth.get_transaction_count(sender_address)
if "accessList" in tx_obj:
access_list = decode_web3_access_list(tx_obj["accessList"])
chain_id = self._w3.eth.chain_id
if not chain_id:
raise ValueError("No chain ID found?")
# NOTE: web3.py's weird async typing
# TODO: Support web3.py's async providers
assert isinstance(nonce, int)
signed_tx = create_transaction(
# TODO: web3.py typing suggests this could be corotine?
chain_id=cast(int, chain_id),
destination=tx_obj.get("to"),
amount=int(value, 16),
gas=int(gas, 16),
gas_price=int(gas_price, 16) if gas_price else None,
max_fee_per_gas=int(max_fee_per_gas, 16) if max_fee_per_gas else None,
max_priority_fee_per_gas=(
int(max_priority_fee_per_gas, 16)
if max_priority_fee_per_gas
else None
),
nonce=nonce,
data=tx_obj.get("data", b""),
sender_path=sender_account.path,
access_list=access_list,
dongle=self._dongle,
)
new_params.append(signed_tx.rawTransaction)
# Change to raw tx call
method = cast(RPCEndpoint, "eth_sendRawTransaction")
params = new_params
return make_request(method, params)
def _handle_eth_sign(self, params: Any) -> RPCResponse:
"""Handler for eth_sign RPC calls."""
if len(params) != 2:
raise ValueError("Unexpected RPC request params length for eth_sign")
account = params[0]
message = decode_hex(params[1])
signer_account = find_account(account, dongle=self._dongle)
if not signer_account:
raise AccountNotFoundError(f"Account {account} not found")
signed = sign_message(message, signer_account.path, dongle=self._dongle)
return _make_response(signed.signature)
def _handle_eth_sign_typed_data(self, params: Any) -> RPCResponse:
"""Handler for eth_signTypedData RPC calls."""
if len(params) != 2:
raise ValueError("Unexpected RPC request params length for eth_sign")
account = params[0]
typed_data = params[1]
if not isinstance(typed_data, dict):
raise TypeError(
"Expected type data to be a dictionary for second param for "
"eth_signTypedData call"
)
# Use eth_account to encode and hash the typed data
signable = encode_typed_data(full_message=typed_data)
domain_hash = signable.header
message_hash = signable.body
# Find the account and sign with Ledger
signer_account = find_account(account, dongle=self._dongle)
if not signer_account:
raise AccountNotFoundError(f"Account {account} not found on Ledger device.")
signed = sign_typed_data_draft(
domain_hash, message_hash, signer_account.path, dongle=self._dongle
)
return _make_response(signed.signature)