mirror of
https://github.com/trezor/trezor-firmware.git
synced 2025-07-02 21:02:34 +00:00

Based on original contribution by Max Kupriianov <xlab@hey.com> Implemented EIP-712 typed data signatures in Ethereum app. Add eth_abi into pyproject deps device test for EIP 712 fixed hex decoding for address fixup! fixed hex decoding for address code quality, more pythonic code, removing unused imports running black and isort on changed files trezorctl file input for EIP 712 data signing fixup! code quality, more pythonic code, removing unused imports fixup! fixup! code quality, more pythonic code, removing unused imports necessary changes after rebase to master unit tests for sign_typed_data.py new protobuf messages, working for nonarray types simplified and verified solution for our simple data support for simple arrays, without their confirmation reverting protobuf value messages to bytes, appropriate changes showing arrays in Trezor, code quality improvements data validation on Trezor, minor improvements using custom types for storing type data instead of dicts, addressing feedback from review moving helper functions to its own file, tests for decode_data additional overall tests support for arrays of structs adding support for metamask_v4_compat variable using HashWriter object to collect the final hash continously minor improvements in code quality validate_field_type function streaming values from client without saving them, missing UI prototype of streamed UI using confirm_properties accounting for bytes in data, more data types in integration tests rebase on master, using f-strings minor fixes and improvements from code review StructHasher class for the whole hashing process mypy and style changes asking users whether to show structs and arrays protobuf descriptions to fix make defs_check unifying comments, mypy fix unit tests for StructHasher class UI fixtures, skipping device tests for T1 addressing majority of code review comments about code quality and structure changing file structure - layouts, helpers, sign_typed_data decode_data renaming and docstring, renaming unit test file using tuples instead of lists in elifs layout improvements excluding core/src/apps/common/confirm.py file from the PR True/False returning layout with Show more button code review layout improvements forgotten br_type argument to should_show_more
423 lines
12 KiB
Python
423 lines
12 KiB
Python
# This file is part of the Trezor project.
|
|
#
|
|
# Copyright (C) 2012-2019 SatoshiLabs and contributors
|
|
#
|
|
# This library is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Lesser General Public License version 3
|
|
# as published by the Free Software Foundation.
|
|
#
|
|
# This library is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Lesser General Public License for more details.
|
|
#
|
|
# You should have received a copy of the License along with this library.
|
|
# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from decimal import Decimal
|
|
from typing import List
|
|
|
|
import click
|
|
|
|
from .. import ethereum, tools
|
|
from . import with_client
|
|
|
|
try:
|
|
import rlp
|
|
import web3
|
|
|
|
HAVE_SIGN_TX = True
|
|
except Exception:
|
|
HAVE_SIGN_TX = False
|
|
|
|
|
|
PATH_HELP = "BIP-32 path, e.g. m/44'/60'/0'/0/0"
|
|
|
|
# fmt: off
|
|
ETHER_UNITS = {
|
|
'wei': 1,
|
|
'kwei': 1000,
|
|
'babbage': 1000,
|
|
'femtoether': 1000,
|
|
'mwei': 1000000,
|
|
'lovelace': 1000000,
|
|
'picoether': 1000000,
|
|
'gwei': 1000000000,
|
|
'shannon': 1000000000,
|
|
'nanoether': 1000000000,
|
|
'nano': 1000000000,
|
|
'szabo': 1000000000000,
|
|
'microether': 1000000000000,
|
|
'micro': 1000000000000,
|
|
'finney': 1000000000000000,
|
|
'milliether': 1000000000000000,
|
|
'milli': 1000000000000000,
|
|
'ether': 1000000000000000000,
|
|
'eth': 1000000000000000000,
|
|
}
|
|
# fmt: on
|
|
|
|
|
|
def _amount_to_int(ctx, param, value):
|
|
if value is None:
|
|
return None
|
|
if value.isdigit():
|
|
return int(value)
|
|
try:
|
|
number, unit = re.match(r"^(\d+(?:.\d+)?)([a-z]+)", value).groups()
|
|
scale = ETHER_UNITS[unit]
|
|
decoded_number = Decimal(number)
|
|
return int(decoded_number * scale)
|
|
|
|
except Exception:
|
|
raise click.BadParameter("Amount not understood")
|
|
|
|
|
|
def _parse_access_list(ctx, param, value):
|
|
try:
|
|
return [_parse_access_list_item(val) for val in value]
|
|
|
|
except Exception:
|
|
raise click.BadParameter("Access List format invalid")
|
|
|
|
|
|
def _parse_access_list_item(value):
|
|
try:
|
|
arr = value.split(":")
|
|
address, storage_keys = arr[0], arr[1:]
|
|
storage_keys_bytes = [ethereum.decode_hex(key) for key in storage_keys]
|
|
return ethereum.messages.EthereumAccessList(address, storage_keys_bytes)
|
|
|
|
except Exception:
|
|
raise click.BadParameter("Access List format invalid")
|
|
|
|
|
|
def _list_units(ctx, param, value):
|
|
if not value or ctx.resilient_parsing:
|
|
return
|
|
maxlen = max(len(k) for k in ETHER_UNITS.keys()) + 1
|
|
for unit, scale in ETHER_UNITS.items():
|
|
click.echo("{:{maxlen}}: {}".format(unit, scale, maxlen=maxlen))
|
|
ctx.exit()
|
|
|
|
|
|
def _erc20_contract(w3, token_address, to_address, amount):
|
|
min_abi = [
|
|
{
|
|
"name": "transfer",
|
|
"type": "function",
|
|
"constant": False,
|
|
"inputs": [
|
|
{"name": "_to", "type": "address"},
|
|
{"name": "_value", "type": "uint256"},
|
|
],
|
|
"outputs": [{"name": "", "type": "bool"}],
|
|
}
|
|
]
|
|
contract = w3.eth.contract(address=token_address, abi=min_abi)
|
|
return contract.encodeABI("transfer", [to_address, amount])
|
|
|
|
|
|
def _format_access_list(access_list: List[ethereum.messages.EthereumAccessList]):
|
|
mapped = map(
|
|
lambda item: [ethereum.decode_hex(item.address), item.storage_keys],
|
|
access_list,
|
|
)
|
|
return list(mapped)
|
|
|
|
|
|
#####################
|
|
#
|
|
# commands start here
|
|
|
|
|
|
@click.group(name="ethereum")
|
|
def cli():
|
|
"""Ethereum commands."""
|
|
|
|
|
|
@cli.command()
|
|
@click.option("-n", "--address", required=True, help=PATH_HELP)
|
|
@click.option("-d", "--show-display", is_flag=True)
|
|
@with_client
|
|
def get_address(client, address, show_display):
|
|
"""Get Ethereum address in hex encoding."""
|
|
address_n = tools.parse_path(address)
|
|
return ethereum.get_address(client, address_n, show_display)
|
|
|
|
|
|
@cli.command()
|
|
@click.option("-n", "--address", required=True, help=PATH_HELP)
|
|
@click.option("-d", "--show-display", is_flag=True)
|
|
@with_client
|
|
def get_public_node(client, address, show_display):
|
|
"""Get Ethereum public node of given path."""
|
|
address_n = tools.parse_path(address)
|
|
result = ethereum.get_public_node(client, address_n, show_display=show_display)
|
|
return {
|
|
"node": {
|
|
"depth": result.node.depth,
|
|
"fingerprint": "%08x" % result.node.fingerprint,
|
|
"child_num": result.node.child_num,
|
|
"chain_code": result.node.chain_code.hex(),
|
|
"public_key": result.node.public_key.hex(),
|
|
},
|
|
"xpub": result.xpub,
|
|
}
|
|
|
|
|
|
@cli.command()
|
|
@click.option(
|
|
"-c", "--chain-id", type=int, default=1, help="EIP-155 chain id (replay protection)"
|
|
)
|
|
@click.option("-n", "--address", required=True, help=PATH_HELP)
|
|
@click.option(
|
|
"-g", "--gas-limit", type=int, help="Gas limit (required for offline signing)"
|
|
)
|
|
@click.option(
|
|
"-t",
|
|
"--gas-price",
|
|
help="Gas price (required for offline signing)",
|
|
callback=_amount_to_int,
|
|
)
|
|
@click.option(
|
|
"-i", "--nonce", type=int, help="Transaction counter (required for offline signing)"
|
|
)
|
|
@click.option("-d", "--data", help="Data as hex string, e.g. 0x12345678")
|
|
@click.option("-p", "--publish", is_flag=True, help="Publish transaction via RPC")
|
|
@click.option("-x", "--tx-type", type=int, help="TX type")
|
|
@click.option("-t", "--token", help="ERC20 token address")
|
|
@click.option(
|
|
"-a",
|
|
"--access-list",
|
|
help="Access List",
|
|
callback=_parse_access_list,
|
|
multiple=True,
|
|
)
|
|
@click.option("--max-gas-fee", help="Max Gas Fee (EIP1559)", callback=_amount_to_int)
|
|
@click.option(
|
|
"--max-priority-fee",
|
|
help="Max Priority Fee (EIP1559)",
|
|
callback=_amount_to_int,
|
|
)
|
|
@click.option("-e", "--eip2718-type", type=int, help="EIP2718 tx type")
|
|
@click.option(
|
|
"--list-units",
|
|
is_flag=True,
|
|
help="List known currency units and exit.",
|
|
is_eager=True,
|
|
callback=_list_units,
|
|
expose_value=False,
|
|
)
|
|
@click.argument("to_address")
|
|
@click.argument("amount", callback=_amount_to_int)
|
|
@with_client
|
|
def sign_tx(
|
|
client,
|
|
chain_id,
|
|
address,
|
|
amount,
|
|
gas_limit,
|
|
gas_price,
|
|
nonce,
|
|
data,
|
|
publish,
|
|
to_address,
|
|
tx_type,
|
|
token,
|
|
max_gas_fee,
|
|
max_priority_fee,
|
|
access_list,
|
|
eip2718_type,
|
|
):
|
|
"""Sign (and optionally publish) Ethereum transaction.
|
|
|
|
Use TO_ADDRESS as destination address, or set to "" for contract creation.
|
|
|
|
Specify a contract address with the --token option to send an ERC20 token.
|
|
|
|
You can specify AMOUNT and gas price either as a number of wei,
|
|
or you can use a unit suffix.
|
|
|
|
Use the --list-units option to show all known currency units.
|
|
ERC20 token amounts are specified in eth/wei, custom units are not supported.
|
|
|
|
If any of gas price, gas limit and nonce is not specified, this command will
|
|
try to connect to an ethereum node and auto-fill these values. You can configure
|
|
the connection with WEB3_PROVIDER_URI environment variable.
|
|
"""
|
|
if not HAVE_SIGN_TX:
|
|
click.echo("Ethereum requirements not installed.")
|
|
click.echo("Please run:")
|
|
click.echo()
|
|
click.echo(" pip install web3 rlp")
|
|
sys.exit(1)
|
|
|
|
is_eip1559 = eip2718_type == 2
|
|
w3 = web3.Web3()
|
|
if (
|
|
(not is_eip1559 and gas_price is None)
|
|
or any(x is None for x in (gas_limit, nonce))
|
|
or publish
|
|
) and not w3.isConnected():
|
|
click.echo("Failed to connect to Ethereum node.")
|
|
click.echo(
|
|
"If you want to sign offline, make sure you provide --gas-price, "
|
|
"--gas-limit and --nonce arguments"
|
|
)
|
|
sys.exit(1)
|
|
|
|
if data is not None and token is not None:
|
|
click.echo("Can't send tokens and custom data at the same time")
|
|
sys.exit(1)
|
|
|
|
address_n = tools.parse_path(address)
|
|
from_address = ethereum.get_address(client, address_n)
|
|
|
|
if token:
|
|
data = _erc20_contract(w3, token, to_address, amount)
|
|
to_address = token
|
|
amount = 0
|
|
|
|
if data:
|
|
data = ethereum.decode_hex(data)
|
|
else:
|
|
data = b""
|
|
|
|
if gas_price is None and not is_eip1559:
|
|
gas_price = w3.eth.gasPrice
|
|
|
|
if gas_limit is None:
|
|
gas_limit = w3.eth.estimateGas(
|
|
{
|
|
"to": to_address,
|
|
"from": from_address,
|
|
"value": amount,
|
|
"data": f"0x{data.hex()}",
|
|
}
|
|
)
|
|
|
|
if nonce is None:
|
|
nonce = w3.eth.getTransactionCount(from_address)
|
|
|
|
sig = (
|
|
ethereum.sign_tx_eip1559(
|
|
client,
|
|
n=address_n,
|
|
nonce=nonce,
|
|
gas_limit=gas_limit,
|
|
to=to_address,
|
|
value=amount,
|
|
data=data,
|
|
chain_id=chain_id,
|
|
max_gas_fee=max_gas_fee,
|
|
max_priority_fee=max_priority_fee,
|
|
access_list=access_list,
|
|
)
|
|
if is_eip1559
|
|
else ethereum.sign_tx(
|
|
client,
|
|
n=address_n,
|
|
tx_type=tx_type,
|
|
nonce=nonce,
|
|
gas_price=gas_price,
|
|
gas_limit=gas_limit,
|
|
to=to_address,
|
|
value=amount,
|
|
data=data,
|
|
chain_id=chain_id,
|
|
)
|
|
)
|
|
|
|
to = ethereum.decode_hex(to_address)
|
|
if is_eip1559:
|
|
transaction = rlp.encode(
|
|
(
|
|
chain_id,
|
|
nonce,
|
|
max_priority_fee,
|
|
max_gas_fee,
|
|
gas_limit,
|
|
to,
|
|
amount,
|
|
data,
|
|
_format_access_list(access_list) if access_list is not None else [],
|
|
)
|
|
+ sig
|
|
)
|
|
elif tx_type is None:
|
|
transaction = rlp.encode((nonce, gas_price, gas_limit, to, amount, data) + sig)
|
|
else:
|
|
transaction = rlp.encode(
|
|
(tx_type, nonce, gas_price, gas_limit, to, amount, data) + sig
|
|
)
|
|
if eip2718_type is not None:
|
|
eip2718_prefix = f"{eip2718_type:02x}"
|
|
else:
|
|
eip2718_prefix = ""
|
|
tx_hex = f"0x{eip2718_prefix}{transaction.hex()}"
|
|
|
|
if publish:
|
|
tx_hash = w3.eth.sendRawTransaction(tx_hex).hex()
|
|
return f"Transaction published with ID: {tx_hash}"
|
|
else:
|
|
return f"Signed raw transaction:\n{tx_hex}"
|
|
|
|
|
|
@cli.command()
|
|
@click.option("-n", "--address", required=True, help=PATH_HELP)
|
|
@click.argument("message")
|
|
@with_client
|
|
def sign_message(client, address, message):
|
|
"""Sign message with Ethereum address."""
|
|
address_n = tools.parse_path(address)
|
|
ret = ethereum.sign_message(client, address_n, message)
|
|
output = {
|
|
"message": message,
|
|
"address": ret.address,
|
|
"signature": f"0x{ret.signature.hex()}",
|
|
}
|
|
return output
|
|
|
|
|
|
@cli.command()
|
|
@click.option("-n", "--address", required=True, help=PATH_HELP)
|
|
@click.option(
|
|
"--metamask-v4-compat/--no-metamask-v4-compat",
|
|
default=True,
|
|
help="Be compatible with Metamask's signTypedData_v4 implementation",
|
|
)
|
|
@click.argument("file", type=click.File("r"))
|
|
@with_client
|
|
def sign_typed_data(client, address, metamask_v4_compat, file):
|
|
"""Sign typed data (EIP-712) with Ethereum address.
|
|
|
|
Currently NOT supported:
|
|
- arrays of arrays
|
|
- recursive structs
|
|
"""
|
|
address_n = tools.parse_path(address)
|
|
data = json.loads(file.read())
|
|
ret = ethereum.sign_typed_data(
|
|
client, address_n, data, metamask_v4_compat=metamask_v4_compat
|
|
)
|
|
output = {
|
|
"address": ret.address,
|
|
"signature": f"0x{ret.signature.hex()}",
|
|
}
|
|
return output
|
|
|
|
|
|
@cli.command()
|
|
@click.argument("address")
|
|
@click.argument("signature")
|
|
@click.argument("message")
|
|
@with_client
|
|
def verify_message(client, address, signature, message):
|
|
"""Verify message signed with Ethereum address."""
|
|
signature = ethereum.decode_hex(signature)
|
|
return ethereum.verify_message(client, address, signature, message)
|