Source code for ledgereth.objects

from __future__ import annotations

from abc import ABC, abstractmethod
from enum import IntEnum
from typing import Any, Dict, List, Optional, Tuple

from eth_utils import encode_hex, to_checksum_address
from rlp import Serializable, decode, encode
from rlp.sedes import BigEndianInt, Binary, CountableList
from rlp.sedes import List as ListSedes
from rlp.sedes import big_endian_int, binary

from ledgereth.constants import DEFAULT_CHAIN_ID
from ledgereth.utils import (
    coerce_list_types,
    is_bip32_path,
    is_bytes,
    is_optional_bytes,
    parse_bip32_path,
)

address = Binary.fixed_length(20, allow_empty=False)
address_allow_empty = Binary.fixed_length(20, allow_empty=True)
access_list_sede_type = CountableList(
    ListSedes(
        [
            address,
            CountableList(BigEndianInt(32)),
        ]
    ),
)
RPC_TX_PROP_TRANSLATION = {
    "gas_price": "gasPrice",
    "gas_limit": "gas",
    "amount": "value",
    "destination": "to",
    "max_priority_fee_per_gas": "maxPriorityFeePerGas",
    "max_fee_per_gas": "maxFeePerGas",
    "access_list": "accessList",
    "chain_id": "chainId",
}
RPC_TX_PROPS = [
    "chainId",
    "from",
    "to",
    "gas",
    "gasPrice",
    "value",
    "data",
    "nonce",
    "maxFeePerGas",
    "maxPriorityFeePerGas",
    "chainId",
    "accessList",
]
MAX_LEGACY_CHAIN_ID = 0xFFFFFFFF + 1
MAX_CHAIN_ID = 0x38D7EA4C67FFF


[docs]class TransactionType(IntEnum): """An Ethereum EIP-2718 transaction type""" #: Original and EIP-155 LEGACY = 0 #: Type-1 (Access Lists) EIP_2930 = 1 #: Type-2 (Transaction fee change to max fee and priority fee) EIP_1559 = 2
[docs] def to_byte(self): """Decode TransactionType to a single byte""" return self.value.to_bytes(1, "big")
[docs]class ISO7816Command: """A representation of an ISO-7816 APDU Command binary to be sent to the Ledger device.""" def __init__( self, CLA: bytes, INS: bytes, P1: bytes, P2: bytes, Lc: Optional[bytes] = None, Le: Optional[bytes] = None, data: Optional[bytes] = None, ): if not ( is_bytes(CLA) and is_bytes(INS) and is_bytes(P1) and is_bytes(P2) and is_optional_bytes(Lc) and is_optional_bytes(Le) and is_optional_bytes(data) ): raise TypeError("Command parts must be type bytes") self.CLA = CLA self.INS = INS self.P1 = P1 self.P2 = P2 self.Lc = Lc or b"\x00" if not data else len(data).to_bytes(1, "big") self.Le = Le self.data = data
[docs] def set_data(self, data: bytes, Lc: Optional[bytes] = None) -> None: """Set the command data and its length :param data: (:class:`bytes`) - The raw ``bytes`` data. This should not exceed the max chunk length of 255 (including command data) :param Lc: (:class:`bytes`) - The length of the data """ self.data = data if len(self.data) > 255: # TODO: Warning? return if Lc is None: self.Lc = len(self.data).to_bytes(1, "big") else: self.Lc = Lc
[docs] def encode(self) -> bytes: """Encode the command into ``bytes`` to be sent to the Ledger device. :return: Encoded ``bytes`` data """ encoded = self.CLA + self.INS + self.P1 + self.P2 if self.data is not None: if self.Lc is None: self.Lc = (len(self.data)).to_bytes(1, "big") encoded += self.Lc encoded += self.data else: encoded += self.Lc if self.Le is not None: encoded += self.Le return encoded
[docs] def encode_hex(self) -> str: """Encode the command into hex bytes representation. :return: Encoded hex ``str`` """ return self.encode().hex()
[docs]class LedgerAccount: """A representation of an account derived from the private key on a Ledger device.""" #: The HD path of the account path: str #: The HD path of the account path_encoded: bytes #: The account's address address: str
[docs] def __init__(self, path, address): """Initialize an account. :param path: (``str``) Derivation path for the account :param address: (``str``) Address of the account """ if not is_bip32_path(path): raise ValueError("Invalid BIP32 Ethereum path") self.path = path self.path_encoded = parse_bip32_path(path) self.address = to_checksum_address(address)
def __repr__(self): return f"<ledgereth.objects.LedgerAccount {self.address}>" def __eq__(self, other): if isinstance(other, LedgerAccount): return self.path == other.path and self.address == other.address return False def __hash__(self): return hash((self.path, self.address))
[docs]class SerializableTransaction(Serializable): """An RLP Serializable transaction object"""
[docs] @classmethod @abstractmethod def from_rawtx(cls, rawtx: bytes) -> SerializableTransaction: """Instantiates a SerializableTransaction given a raw encoded transaction :param rawtx: (:class:`bytes`) - The decoded raw transaction ``bytes`` to encode into a :class:`ledgereth.objects.SerializableTransaction` :return: Instantiated :class:`ledgereth.objects.SerializableTransaction` """
[docs] def to_dict(self) -> Dict[str, Any]: """Return a dictionary representation of the transaction :return: Transaction dict """ d = {} for name, _ in self.__class__._meta.fields: d[name] = getattr(self, name) return d
[docs] def to_rpc_dict(self) -> Dict[str, Any]: """To a dict compatible with web3.py or JSON-RPC :return: Transaction dict """ d: Dict[str, Any] = {} for name, _ in self.__class__._meta.fields: key = ( RPC_TX_PROP_TRANSLATION[name] if name in RPC_TX_PROP_TRANSLATION else name ) if key in RPC_TX_PROPS: # Need to format an access list differently for web3/RPC-like # objects. It expects a list of objects if key == "accessList": orig = getattr(self, name) d[key] = [] for item in orig: d[key].append( { "address": item[0], "storageKeys": [ int.from_bytes(slot, "big") for slot in item[1] ], } ) else: d[key] = getattr(self, name) return d
[docs]class Transaction(SerializableTransaction): """Unsigned legacy or `EIP-155`_ transaction .. warning:: chain_id for type 0 ("Legacy") transactions must be less than 4294967295, the largest 32-bit unsigned integer. .. note:: A chain_id is set by default (``1``). It is not required to be a valid legacy transaction, but without it your transaction is suceptible to replay attack. If for some reason you absolutely do not want it in your tx, set it to ``None``. .. _`EIP-155`: https://eips.ethereum.org/EIPS/eip-155 """ fields = [ ("nonce", big_endian_int), ("gas_price", big_endian_int), ("gas_limit", big_endian_int), ("destination", address_allow_empty), ("amount", big_endian_int), ("data", binary), ("chain_id", big_endian_int), # Expected nine elements as part of EIP-155 transactions ("dummy1", big_endian_int), ("dummy2", big_endian_int), ] #: The EIP-2718 transaction type transaction_type = TransactionType.LEGACY
[docs] def __init__( self, nonce: int, gas_price: int, gas_limit: int, destination: bytes, amount: int, data: bytes, chain_id: int = DEFAULT_CHAIN_ID, dummy1: int = 0, dummy2: int = 0, ): """Initialize an unsigned transaction :param nonce: (``int``) Transaction nonce :param gas_price: (``int``) Gas price in wei :param gas_limit: (``int``) Gas limit :param destination: (``bytes``) Destination address :param amount: (``int``) Amount of Ether to send in wei :param data: (``bytes``) Transaction data :param chain_id: (``int``) Chain ID :param dummy1: (``int``) **DO NOT SET** :param dummy2: (``int``) **DO NOT SET** """ if chain_id > MAX_LEGACY_CHAIN_ID: """Chain IDs above 32-bits seems to cause app-ethereum to create invalid signatures. It's not yet clear why this is, or where the bug is, or even if it's a bug. See the following issue for details: https://github.com/mikeshultz/ledger-eth-lib/issues/41 """ raise ValueError( "chain_id must be a 32-bit integer for type 0 transactions. (See issue #41)" ) super().__init__( nonce, gas_price, gas_limit, destination, amount, data, chain_id, dummy1, dummy2, )
[docs] @classmethod def from_rawtx(cls, rawtx: bytes) -> Transaction: """Instantiate a Transaction object from a raw encoded transaction :param rawtx: (``bytes``) A raw transaction to instantiate with :returns: :class:`ledgereth.objects.Transaction` """ if rawtx[0] < 127: raise ValueError("Transaction is not a legacy transaction") return Transaction( *coerce_list_types( [int, int, int, bytes, int, bytes, int, int, int], decode(rawtx) ) )
[docs]class Type1Transaction(SerializableTransaction): """An unsigned Type 1 transaction. .. warning:: chain_id for type 1 transactions must be less than 999999999999999, the largest unsigned integer that the device can render on-screen. Encoded tx format spec: .. code:: 0x01 || rlp([chainId, nonce, gasPrice, gasLimit, destination, amount, data, accessList]) """ fields = [ ("chain_id", big_endian_int), ("nonce", big_endian_int), ("gas_price", big_endian_int), ("gas_limit", big_endian_int), ("destination", address_allow_empty), ("amount", big_endian_int), ("data", binary), ("access_list", access_list_sede_type), ] #: The EIP-2718 transaction type transaction_type = TransactionType.EIP_2930
[docs] def __init__( self, chain_id: int, nonce: int, gas_price: int, gas_limit: int, destination: bytes, amount: int, data: bytes, access_list: Optional[List[Tuple[bytes, List[int]]]] = None, ): """Initialize an unsigned type 2 transaction :param chain_id: (``int``) Chain ID :param nonce: (``int``) Transaction nonce :param gas_price: (``int``) Gas price in wei :param gas_limit: (``int``) Gas limit :param destination: (``bytes``) Destination address :param amount: (``int``) Amount of Ether to send in wei :param data: (``bytes``) Transaction data :param access_list: (``Optional[List[Tuple[bytes, List[int]]]]``) EIP-2718 Access list """ access_list = access_list or [] if chain_id > MAX_CHAIN_ID: """Chain IDs above 999999999999999 cause app-ethereum to throw an error because its unable to render on the device. Ref: https://github.com/mikeshultz/ledger-eth-lib/issues/41 Ref: https://github.com/LedgerHQ/app-ethereum/issues/283 """ raise ValueError( "chain_id must not be above 999999999999999. (See issue #41)" ) super().__init__( chain_id, nonce, gas_price, gas_limit, destination, amount, data, access_list, )
[docs] @classmethod def from_rawtx(cls, rawtx: bytes) -> Type1Transaction: """Instantiate a Type1Transaction object from a raw encoded transaction :param rawtx: (``bytes``) A raw transaction to instantiate with :returns: :class:`ledgereth.objects.Type1Transaction` """ if rawtx[0] != cls.transaction_type: raise ValueError( f"Transaction is not a type {cls.transaction_type} transaction" ) return Type1Transaction( *coerce_list_types( [int, int, int, int, bytes, int, bytes, None], decode(rawtx[1:]), ) )
[docs]class Type2Transaction(SerializableTransaction): """An unsigned Type 2 transaction. .. warning:: chain_id for type 2 transactions must be less than 999999999999999, the largest unsigned integer that the device can render on-screen. Encoded TX format spec: .. code:: 0x02 || rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list]) """ fields = [ ("chain_id", big_endian_int), ("nonce", big_endian_int), ("max_priority_fee_per_gas", big_endian_int), ("max_fee_per_gas", big_endian_int), ("gas_limit", big_endian_int), ("destination", address_allow_empty), ("amount", big_endian_int), ("data", binary), ("access_list", access_list_sede_type), ] #: The EIP-2718 transaction type transaction_type = TransactionType.EIP_1559
[docs] def __init__( self, chain_id: int, nonce: int, max_priority_fee_per_gas: int, max_fee_per_gas: int, gas_limit: int, destination: bytes, amount: int, data: bytes, access_list: Optional[List[Tuple[bytes, List[int]]]] = None, ): """Initialize an unsigned type 2 transaction :param chain_id: (``int``) Chain ID :param nonce: (``int``) Transaction nonce :param max_priority_fee_per_gas: (``int``) Priority fee per gas (in wei) to provide to the miner of the block. :param max_fee_per_gas: (``int``) Maximum fee in wei to pay for the transaction. This is not compatible with :code:`gas_price`. :param gas_limit: (``int``) Gas limit :param destination: (``bytes``) Destination address :param amount: (``int``) Amount of Ether to send in wei :param data: (``bytes``) Transaction data :param access_list: (``List[Tuple[bytes, List[int]]]``) EIP-2718 Access list """ access_list = access_list or [] if chain_id > MAX_CHAIN_ID: """Chain IDs above 999999999999999 cause app-ethereum to throw an error because its unable to render on the device. Ref: https://github.com/mikeshultz/ledger-eth-lib/issues/41 Ref: https://github.com/LedgerHQ/app-ethereum/issues/283 """ raise ValueError( "chain_id must not be above 999999999999999. (See issue #41)" ) super().__init__( chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list, )
[docs] @classmethod def from_rawtx(cls, rawtx: bytes) -> Type2Transaction: """Instantiate a Type2Transaction object from a raw encoded transaction :param rawtx: (``bytes``) A raw transaction to instantiate with :returns: :class:`ledgereth.objects.Type2Transaction` """ if rawtx[0] != cls.transaction_type: raise ValueError( f"Transaction is not a type {cls.transaction_type} transaction" ) return Type2Transaction( *coerce_list_types( [int, int, int, int, int, bytes, int, bytes, None], decode(rawtx[1:]), ) )
[docs]class SignedTransaction(SerializableTransaction): """Signed legacy or EIP-155 transaction""" fields = [ ("nonce", big_endian_int), ("gas_price", big_endian_int), ("gas_limit", big_endian_int), ("destination", address_allow_empty), ("amount", big_endian_int), ("data", binary), ("v", big_endian_int), ("r", big_endian_int), ("s", big_endian_int), ] #: The EIP-2718 transaction type transaction_type = TransactionType.LEGACY
[docs] def __init__( self, nonce: int, gas_price: int, gas_limit: int, destination: bytes, amount: int, data: bytes, v: int, r: int, s: int, ): """Initialize an unsigned transaction :param nonce: (``int``) Transaction nonce :param gas_price: (``int``) Gas price in wei :param gas_limit: (``int``) Gas limit :param destination: (``bytes``) Destination address :param amount: (``int``) Amount of Ether to send in wei :param data: (``bytes``) Transaction data :param v: (``int``) Signature v value :param r: (``int``) Signature r value :param s: (``int``) Signature s value """ super().__init__( nonce, gas_price, gas_limit, destination, amount, data, v, r, s )
[docs] @classmethod def from_rawtx(cls, rawtx: bytes) -> SignedTransaction: """Instantiate a SignedTransaction object from a raw encoded transaction :param rawtx: (``bytes``) A raw signed transaction to instantiate with :returns: :class:`ledgereth.objects.SignedTransaction` """ if rawtx[0] < 127: raise ValueError("Transaction is not a legacy transaction") return SignedTransaction( *coerce_list_types([int, int, int, int, bytes, int, bytes], decode(rawtx)) )
[docs] def raw_transaction(self): """Return an encoded raw signed transaction Encoded signed TX format spec: .. code:: rlp([nonce, gasPrice, gasLimit, destination, amount, data, signatureV, signatureR, signatureS]) :returns: Encoded raw signed transaction bytes """ return encode_hex(encode(self, SignedTransaction))
# Match the API of the web3.py Transaction object #: Encoded raw signed transaction rawTransaction = property(raw_transaction)
[docs]class SignedType1Transaction(SerializableTransaction): """A signed Type 1 transaction.""" fields = [ ("chain_id", big_endian_int), ("nonce", big_endian_int), ("gas_price", big_endian_int), ("gas_limit", big_endian_int), ("destination", address_allow_empty), ("amount", big_endian_int), ("data", binary), ("access_list", access_list_sede_type), ("y_parity", big_endian_int), ("sender_r", big_endian_int), ("sender_s", big_endian_int), ] #: The EIP-2718 transaction type transaction_type = TransactionType.EIP_2930
[docs] def __init__( self, chain_id: int, nonce: int, gas_price: int, gas_limit: int, destination: bytes, amount: int, data: bytes, access_list: List[Tuple[bytes, List[int]]], y_parity: int, sender_r: int, sender_s: int, ): """Initialize a signed type 1 transaction :param chain_id: (``int``) Chain ID :param nonce: (``int``) Transaction nonce :param gas_price: (``int``) Gas price in wei :param gas_limit: (``int``) Gas limit :param destination: (``bytes``) Destination address :param amount: (``int``) Amount of Ether to send in wei :param data: (``bytes``) Transaction data :param access_list: (``List[Tuple[bytes, List[int]]]``) EIP-2718 Access list :param y_parity: (``int``) Parity byte for the signature :param sender_r: (``int``) Signature r value :param sender_s: (``int``) Signature s value """ super().__init__( chain_id, nonce, gas_price, gas_limit, destination, amount, data, access_list, y_parity, sender_r, sender_s, )
[docs] @classmethod def from_rawtx(cls, rawtx: bytes) -> SignedType1Transaction: """Instantiate a SignedType1Transaction object from a raw encoded transaction :param rawtx: (``bytes``) A raw signed transaction to instantiate with :returns: :class:`ledgereth.objects.SignedType1Transaction` """ if rawtx[0] != cls.transaction_type: raise ValueError( f"Transaction is not a type {cls.transaction_type} transaction" ) return SignedType1Transaction( *coerce_list_types( [int, int, int, int, bytes, int, bytes, None, int, int, int], decode(rawtx[1:]), ) )
[docs] def raw_transaction(self): """Return an encoded raw signed transaction Encoded signed TX format spec: .. code:: 0x01 || rlp([chainId, nonce, gasPrice, gasLimit, destination, amount, data, accessList, signatureYParity, signatureR, signatureS]) :returns: Encoded raw signed transaction bytes """ return encode_hex(b"\x01" + encode(self, SignedType1Transaction))
# Match the API of the web3.py Transaction object #: Encoded raw signed transaction rawTransaction = property(raw_transaction)
[docs]class SignedType2Transaction(SerializableTransaction): """A signed Type 2 transaction.""" fields = [ ("chain_id", big_endian_int), ("nonce", big_endian_int), ("max_priority_fee_per_gas", big_endian_int), ("max_fee_per_gas", big_endian_int), ("gas_limit", big_endian_int), ("destination", address_allow_empty), ("amount", big_endian_int), ("data", binary), ("access_list", access_list_sede_type), ("y_parity", big_endian_int), ("sender_r", big_endian_int), ("sender_s", big_endian_int), ] #: The EIP-2718 transaction type transaction_type = TransactionType.EIP_1559
[docs] def __init__( self, chain_id: int, nonce: int, max_priority_fee_per_gas: int, max_fee_per_gas: int, gas_limit: int, destination: bytes, amount: int, data: bytes, access_list: List[Tuple[bytes, List[int]]], y_parity: int, sender_r: int, sender_s: int, ): """Initialize a signed type 2 transaction :param chain_id: (``int``) Chain ID :param nonce: (``int``) Transaction nonce :param max_priority_fee_per_gas: (``int``) Priority fee per gas (in wei) to provide to the miner of the block. :param max_fee_per_gas: (``int``) Maximum fee in wei to pay for the transaction. This is not compatible with :code:`gas_price`. :param gas_limit: (``int``) Gas limit :param destination: (``bytes``) Destination address :param amount: (``int``) Amount of Ether to send in wei :param data: (``bytes``) Transaction data :param access_list: (``List[Tuple[bytes, List[int]]]``) EIP-2718 Access list :param y_parity: (``int``) Parity byte for the signature :param sender_r: (``int``) Signature r value :param sender_s: (``int``) Signature s value """ super().__init__( chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list, y_parity, sender_r, sender_s, )
[docs] @classmethod def from_rawtx(cls, rawtx: bytes) -> SignedType2Transaction: """Instantiate a SignedType2Transaction object from a raw encoded transaction :param rawtx: (``bytes``) A raw signed transaction to instantiate with :returns: :class:`ledgereth.objects.SignedType2Transaction` """ if rawtx[0] != cls.transaction_type: raise ValueError( f"Transaction is not a type {cls.transaction_type} transaction" ) return SignedType2Transaction( *coerce_list_types( [ int, int, int, int, int, bytes, int, bytes, None, int, int, int, ], decode(rawtx[1:]), ) )
[docs] def raw_transaction(self): """Return an encoded raw signed transaction Encoded signed TX format spec: .. code:: 0x02 || rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list, signature_y_parity, signature_r, signature_s]) :returns: Encoded raw signed transaction bytes """ return encode_hex(b"\x02" + encode(self, SignedType2Transaction))
# Match the API of the web3.py Transaction object #: Encoded raw signed transaction rawTransaction = property(raw_transaction)
[docs]class Signed(ABC): #: Signature v v: int #: Signature r r: int #: Signature s s: int def __init__(self, v, r, s): self.v = v self.r = r self.s = s @property def signature(self): """Encoded signature :returns: Signature ``bytes`` """ if not self.v or not self.r or not self.s: raise ValueError("Missing v, r, or s") return encode_hex( self.r.to_bytes(32, "big") + self.s.to_bytes(32, "big") + self.v.to_bytes(1, "big") )
[docs]class SignedMessage(Signed): """Signed EIP-191 message""" message: bytes
[docs] def __init__(self, message, v, r, s): """Initialize a singed message :param message: (``bytes``) Message that was signed :param v: (``int``) Signature v value :param r: (``int``) Signature r value :param s: (``int``) Signature s value """ self.message = message super().__init__(v, r, s)
[docs]class SignedTypedMessage(Signed): """Signed EIP-812 typed data""" domain_hash: bytes message_hash: bytes
[docs] def __init__(self, domain_hash, message_hash, v, r, s): """Initialize a singed message :param domain_hash: (``bytes``) Domain hash that was signed :param message_hash: (``bytes``) Message hash that was signed :param v: (``int``) Signature v value :param r: (``int``) Signature r value :param s: (``int``) Signature s value """ self.domain_hash = domain_hash self.message_hash = message_hash super().__init__(v, r, s)