2018-07-20 14:49:45 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
from binascii import unhexlify
|
|
|
|
from collections import defaultdict, OrderedDict
|
|
|
|
import re
|
|
|
|
import os
|
|
|
|
import json
|
|
|
|
import glob
|
|
|
|
import logging
|
|
|
|
|
|
|
|
try:
|
|
|
|
import requests
|
|
|
|
except ImportError:
|
|
|
|
requests = None
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
DEFS_DIR = os.path.abspath(
|
|
|
|
os.environ.get("DEFS_DIR") or os.path.join(os.path.dirname(__file__), "..", "defs")
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def load_json(*path):
|
2018-07-23 14:33:00 +00:00
|
|
|
"""Convenience function to load a JSON file from DEFS_DIR."""
|
2018-07-20 14:49:45 +00:00
|
|
|
if len(path) == 1 and path[0].startswith("/"):
|
|
|
|
filename = path[0]
|
|
|
|
else:
|
|
|
|
filename = os.path.join(DEFS_DIR, *path)
|
|
|
|
|
|
|
|
with open(filename) as f:
|
|
|
|
return json.load(f, object_pairs_hook=OrderedDict)
|
|
|
|
|
|
|
|
|
|
|
|
# ====== coin validation ======
|
|
|
|
|
|
|
|
|
|
|
|
def check_type(val, types, nullable=False, empty=False, regex=None, choice=None):
|
|
|
|
# check nullable
|
|
|
|
if val is None:
|
|
|
|
if nullable:
|
|
|
|
return
|
|
|
|
else:
|
|
|
|
raise ValueError("Missing required value")
|
|
|
|
|
|
|
|
# check type
|
|
|
|
if not isinstance(val, types):
|
|
|
|
raise TypeError("Wrong type (expected: {})".format(types))
|
|
|
|
|
|
|
|
# check empty
|
|
|
|
if isinstance(val, (list, dict)) and not empty and not val:
|
|
|
|
raise ValueError("Empty collection")
|
|
|
|
|
|
|
|
# check regex
|
|
|
|
if regex is not None:
|
|
|
|
if types is not str:
|
|
|
|
raise TypeError("Wrong type for regex check")
|
|
|
|
if not re.search(regex, val):
|
|
|
|
raise ValueError("Value does not match regex {}".format(regex))
|
|
|
|
|
|
|
|
# check choice
|
|
|
|
if choice is not None and val not in choice:
|
|
|
|
raise ValueError("Value not allowed, use one of: {}".format(", ".join(choice)))
|
|
|
|
|
|
|
|
|
|
|
|
def check_key(key, types, **kwargs):
|
|
|
|
def do_check(coin):
|
|
|
|
if not key in coin:
|
|
|
|
raise KeyError("{}: Missing key".format(key))
|
|
|
|
try:
|
|
|
|
check_type(coin[key], types, **kwargs)
|
|
|
|
except Exception as e:
|
|
|
|
raise ValueError("{}: {}".format(key, e)) from e
|
|
|
|
|
|
|
|
return do_check
|
|
|
|
|
|
|
|
|
2018-07-23 14:33:00 +00:00
|
|
|
BTC_CHECKS = [
|
2018-07-20 14:49:45 +00:00
|
|
|
check_key("coin_name", str, regex=r"^[A-Z]"),
|
|
|
|
check_key("coin_shortcut", str, regex=r"^t?[A-Z]{3,}$"),
|
|
|
|
check_key("coin_label", str, regex=r"^[A-Z]"),
|
|
|
|
check_key("website", str, regex=r"^http.*[^/]$"),
|
|
|
|
check_key("github", str, regex=r"^https://github.com/.*[^/]$"),
|
|
|
|
check_key("maintainer", str),
|
|
|
|
check_key(
|
|
|
|
"curve_name", str, choice=["secp256k1", "secp256k1_decred", "secp256k1_groestl"]
|
|
|
|
),
|
|
|
|
check_key("address_type", int),
|
|
|
|
check_key("address_type_p2sh", int),
|
|
|
|
check_key("maxfee_kb", int),
|
|
|
|
check_key("minfee_kb", int),
|
|
|
|
check_key("hash_genesis_block", str, regex=r"^[0-9a-f]{64}$"),
|
|
|
|
check_key("xprv_magic", int),
|
|
|
|
check_key("xpub_magic", int),
|
|
|
|
check_key("xpub_magic_segwit_p2sh", int, nullable=True),
|
|
|
|
check_key("xpub_magic_segwit_native", int, nullable=True),
|
|
|
|
check_key("slip44", int),
|
|
|
|
check_key("segwit", bool),
|
|
|
|
check_key("decred", bool),
|
|
|
|
check_key("fork_id", int, nullable=True),
|
|
|
|
check_key("force_bip143", bool),
|
|
|
|
check_key("bip115", bool),
|
|
|
|
check_key("version_group_id", int, nullable=True),
|
|
|
|
check_key("default_fee_b", dict),
|
|
|
|
check_key("dust_limit", int),
|
|
|
|
check_key("blocktime_seconds", int),
|
|
|
|
check_key("signed_message_header", str),
|
|
|
|
check_key("address_prefix", str, regex=r":$"),
|
|
|
|
check_key("min_address_length", int),
|
|
|
|
check_key("max_address_length", int),
|
|
|
|
check_key("bech32_prefix", str, nullable=True),
|
|
|
|
check_key("cashaddr_prefix", str, nullable=True),
|
|
|
|
check_key("bitcore", list, empty=True),
|
|
|
|
check_key("blockbook", list, empty=True),
|
|
|
|
]
|
|
|
|
|
|
|
|
|
2018-07-23 14:33:00 +00:00
|
|
|
def validate_btc(coin):
|
2018-07-20 14:49:45 +00:00
|
|
|
errors = []
|
2018-07-23 14:33:00 +00:00
|
|
|
for check in BTC_CHECKS:
|
2018-07-20 14:49:45 +00:00
|
|
|
try:
|
|
|
|
check(coin)
|
|
|
|
except Exception as e:
|
|
|
|
errors.append(str(e))
|
|
|
|
|
|
|
|
magics = [
|
|
|
|
coin[k]
|
|
|
|
for k in (
|
|
|
|
"xprv_magic",
|
|
|
|
"xpub_magic",
|
|
|
|
"xpub_magic_segwit_p2sh",
|
|
|
|
"xpub_magic_segwit_native",
|
|
|
|
)
|
|
|
|
if coin[k] is not None
|
|
|
|
]
|
|
|
|
# each of those must be unique
|
|
|
|
# therefore length of list == length of set of unique values
|
|
|
|
if len(magics) != len(set(magics)):
|
|
|
|
errors.append("XPUB/XPRV magic numbers must be unique")
|
|
|
|
|
|
|
|
if coin["address_type"] == coin["address_type_p2sh"]:
|
|
|
|
errors.append("address_type must be distinct from address_type_p2sh")
|
|
|
|
|
|
|
|
if not coin["maxfee_kb"] >= coin["minfee_kb"]:
|
|
|
|
errors.append("max fee must not be smaller than min fee")
|
|
|
|
|
|
|
|
if not coin["max_address_length"] >= coin["min_address_length"]:
|
|
|
|
errors.append("max address length must not be smaller than min address length")
|
|
|
|
|
|
|
|
for bc in coin["bitcore"] + coin["blockbook"]:
|
|
|
|
if bc.endswith("/"):
|
|
|
|
errors.append("make sure URLs don't end with '/'")
|
|
|
|
|
|
|
|
return errors
|
|
|
|
|
|
|
|
|
|
|
|
# ======= Coin json loaders =======
|
|
|
|
|
|
|
|
|
2018-07-23 14:33:00 +00:00
|
|
|
def _load_btc_coins():
|
|
|
|
"""Load btc-like coins from `coins/*.json`"""
|
2018-07-20 14:49:45 +00:00
|
|
|
coins = []
|
|
|
|
for filename in glob.glob(os.path.join(DEFS_DIR, "coins", "*.json")):
|
|
|
|
coin = load_json(filename)
|
|
|
|
coin.update(
|
|
|
|
name=coin["coin_name"],
|
|
|
|
shortcut=coin["coin_shortcut"],
|
2018-07-23 14:33:00 +00:00
|
|
|
key="coin:{}".format(coin["coin_shortcut"]),
|
2018-07-20 14:49:45 +00:00
|
|
|
)
|
|
|
|
coins.append(coin)
|
|
|
|
|
|
|
|
return coins
|
|
|
|
|
|
|
|
|
2018-07-23 14:33:00 +00:00
|
|
|
def _load_ethereum_networks():
|
|
|
|
"""Load ethereum networks from `ethereum/networks.json`"""
|
2018-07-20 14:49:45 +00:00
|
|
|
networks = load_json("ethereum", "networks.json")
|
|
|
|
for network in networks:
|
|
|
|
network.update(key="eth:{}".format(network["shortcut"]))
|
|
|
|
return networks
|
|
|
|
|
|
|
|
|
2018-07-23 14:33:00 +00:00
|
|
|
def _load_erc20_tokens():
|
|
|
|
"""Load ERC20 tokens from `ethereum/tokens` submodule."""
|
|
|
|
networks = _load_ethereum_networks()
|
2018-07-20 14:49:45 +00:00
|
|
|
tokens = []
|
|
|
|
for network in networks:
|
|
|
|
if network["name"].startswith("Ethereum Testnet "):
|
|
|
|
idx = len("Ethereum Testnet ")
|
|
|
|
chain = network["name"][idx : idx + 3]
|
|
|
|
else:
|
|
|
|
chain = network["shortcut"]
|
|
|
|
chain = chain.lower()
|
|
|
|
if not chain:
|
|
|
|
continue
|
|
|
|
|
|
|
|
chain_path = os.path.join(DEFS_DIR, "ethereum", "tokens", "tokens", chain)
|
|
|
|
for filename in glob.glob(os.path.join(chain_path, "*.json")):
|
|
|
|
token = load_json(filename)
|
|
|
|
token.update(
|
|
|
|
chain=chain,
|
|
|
|
chain_id=network["chain_id"],
|
|
|
|
address_bytes=unhexlify(token["address"][2:]),
|
|
|
|
shortcut=token["symbol"],
|
|
|
|
key="erc20:{}:{}".format(chain, token["symbol"]),
|
|
|
|
)
|
|
|
|
tokens.append(token)
|
|
|
|
|
|
|
|
return tokens
|
|
|
|
|
|
|
|
|
2018-07-23 14:33:00 +00:00
|
|
|
def _load_nem_mosaics():
|
|
|
|
"""Loads NEM mosaics from `nem/nem_mosaics.json`"""
|
2018-07-20 14:49:45 +00:00
|
|
|
mosaics = load_json("nem", "nem_mosaics.json")
|
|
|
|
for mosaic in mosaics:
|
|
|
|
shortcut = mosaic["ticker"].strip()
|
|
|
|
mosaic.update(shortcut=shortcut, key="nem:{}".format(shortcut))
|
|
|
|
return mosaics
|
|
|
|
|
|
|
|
|
2018-07-23 14:33:00 +00:00
|
|
|
def _load_misc():
|
|
|
|
"""Loads miscellaneous networks from `misc/misc.json`"""
|
|
|
|
others = load_json("misc/misc.json")
|
2018-07-20 14:49:45 +00:00
|
|
|
for other in others:
|
2018-07-23 14:33:00 +00:00
|
|
|
other.update(key="misc:{}".format(other["shortcut"]))
|
2018-07-20 14:49:45 +00:00
|
|
|
return others
|
|
|
|
|
|
|
|
|
|
|
|
# ====== support info ======
|
|
|
|
|
|
|
|
RELEASES_URL = "https://wallet.trezor.io/data/firmware/{}/releases.json"
|
|
|
|
ETHEREUM_TOKENS = {
|
|
|
|
"1": "https://raw.githubusercontent.com/trezor/trezor-mcu/v{}/firmware/ethereum_tokens.c",
|
|
|
|
"2": "https://raw.githubusercontent.com/trezor/trezor-core/v{}/src/apps/ethereum/tokens.py",
|
|
|
|
}
|
|
|
|
|
|
|
|
TOKEN_MATCH = {
|
|
|
|
"1": re.compile(r'\{.*" ([^"]+)".*\},'),
|
|
|
|
"2": re.compile(r'\(.*["\']([^"\']+)["\'].*\),'),
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def latest_releases():
|
2018-07-23 14:33:00 +00:00
|
|
|
"""Get latest released firmware versions for Trezor 1 and 2"""
|
2018-07-20 14:49:45 +00:00
|
|
|
if not requests:
|
|
|
|
raise RuntimeError("requests library is required for getting release info")
|
|
|
|
|
|
|
|
latest = {}
|
|
|
|
for v in ("1", "2"):
|
|
|
|
releases = requests.get(RELEASES_URL.format(v)).json()
|
|
|
|
latest[v] = max(tuple(r["version"]) for r in releases)
|
|
|
|
return latest
|
|
|
|
|
|
|
|
|
|
|
|
def support_info_erc20(coins, versions):
|
2018-07-23 14:33:00 +00:00
|
|
|
"""Generate support info for ERC20 tokens.
|
|
|
|
|
|
|
|
Takes a dict of Trezor versions as returned from `latest_releases`
|
|
|
|
and a list of coins as returned from `_get_erc20_tokens` and creates
|
|
|
|
a supportinfo entry for each listed token.
|
|
|
|
|
|
|
|
Support status is determined by downloading and parsing the definition file
|
|
|
|
from the appropriate firmware version. If a given token is listed, the support
|
|
|
|
is marked as "yes". If not, support is marked as "soon", assuming that
|
|
|
|
it will be included in next release.
|
|
|
|
|
|
|
|
This is currently the only way to get the list of supported tokens. We don't want
|
|
|
|
to track support individually in support.json.
|
|
|
|
"""
|
2018-07-20 14:49:45 +00:00
|
|
|
supported_tokens = {}
|
|
|
|
for trezor, version in versions.items():
|
|
|
|
tokens = set()
|
|
|
|
version_str = ".".join(map(str, version))
|
|
|
|
|
|
|
|
token_file = requests.get(ETHEREUM_TOKENS[trezor].format(version_str)).text
|
|
|
|
token_match = TOKEN_MATCH[trezor]
|
|
|
|
|
|
|
|
for line in token_file.split("\n"):
|
|
|
|
m = token_match.search(line)
|
|
|
|
if m:
|
|
|
|
tokens.add(m.group(1))
|
|
|
|
|
|
|
|
supported_tokens[trezor] = tokens
|
|
|
|
|
|
|
|
support = {}
|
|
|
|
for coin in coins:
|
|
|
|
key = coin["key"]
|
|
|
|
if not key.startswith("erc20:"):
|
|
|
|
continue
|
|
|
|
|
|
|
|
support[key] = dict(
|
|
|
|
trezor1="yes" if coin["shortcut"] in supported_tokens["1"] else "soon",
|
|
|
|
trezor2="yes" if coin["shortcut"] in supported_tokens["2"] else "soon",
|
|
|
|
)
|
|
|
|
|
|
|
|
return support
|
|
|
|
|
|
|
|
|
2018-07-23 14:33:00 +00:00
|
|
|
def support_info(coins, erc20_versions=None, skip_missing=False):
|
|
|
|
"""Generate Trezor support information.
|
|
|
|
|
|
|
|
Takes a dict of coins and generates a support-info entry for each.
|
|
|
|
The support-info is a dict with a number of known keys:
|
|
|
|
`trezor1`, `trezor2`, `webwallet`, `connect`. An optional `other` entry
|
|
|
|
is a dict of name-url pairs for third-party software.
|
|
|
|
|
|
|
|
For btc-like coins and misc networks, this is taken from `support.json`.
|
|
|
|
For NEM mosaics and ethereum networks, the support is presumed to be "yes"
|
|
|
|
for both Trezors. Webwallet and Connect info is not filled out.
|
|
|
|
|
|
|
|
ERC20 tokens are ignored by this function, as if `skip_missing` was true
|
|
|
|
(see below). However, if you pass the optional `erc20_versions` argument,
|
|
|
|
it will call `support_info_erc20` for you with given versions.
|
|
|
|
|
|
|
|
In all cases, if the coin is explicitly listed in `support.json`, the info
|
|
|
|
takes precedence over any other source (be it assumed "yes" for nem/eth,
|
|
|
|
or downloaded info for erc20).
|
|
|
|
|
|
|
|
If `skip_missing` is `True`, coins for which no support information is available
|
|
|
|
will not be included in the output. Otherwise, an empty dict will be included
|
|
|
|
and a warning emitted. "No support information" means that the coin is not
|
|
|
|
listed in `support.json` and we have no heuristic to determine the support.
|
|
|
|
"""
|
2018-07-20 14:49:45 +00:00
|
|
|
support_data = load_json("support.json")
|
|
|
|
support = {}
|
|
|
|
for coin in coins:
|
|
|
|
key = coin["key"]
|
|
|
|
typ = key.split(":", maxsplit=1)[0]
|
|
|
|
if key in support_data:
|
|
|
|
support[key] = support_data[key]
|
|
|
|
|
|
|
|
elif typ in ("nem", "eth"):
|
|
|
|
support[key] = dict(trezor1="yes", trezor2="yes")
|
|
|
|
|
|
|
|
elif typ == "erc20":
|
|
|
|
# you must call a separate function to get that
|
|
|
|
continue
|
|
|
|
|
2018-07-23 14:33:00 +00:00
|
|
|
elif not skip_missing:
|
2018-07-20 14:49:45 +00:00
|
|
|
log.warning("support info missing for {}".format(key))
|
|
|
|
support[key] = {}
|
|
|
|
|
|
|
|
if erc20_versions:
|
|
|
|
erc20 = support_info_erc20(coins, erc20_versions)
|
|
|
|
erc20.update(support)
|
|
|
|
return erc20
|
|
|
|
else:
|
|
|
|
return support
|
|
|
|
|
|
|
|
|
|
|
|
# ====== data cleanup functions ======
|
|
|
|
|
|
|
|
|
|
|
|
def find_address_collisions(coins):
|
2018-07-23 14:33:00 +00:00
|
|
|
"""Detects collisions in:
|
|
|
|
- SLIP44 path prefixes
|
|
|
|
- address type numbers, both for p2pkh and p2sh
|
|
|
|
"""
|
2018-07-20 14:49:45 +00:00
|
|
|
slip44 = defaultdict(list)
|
|
|
|
at_p2pkh = defaultdict(list)
|
|
|
|
at_p2sh = defaultdict(list)
|
|
|
|
|
|
|
|
for name, coin in coins.items():
|
|
|
|
s = coin["slip44"]
|
|
|
|
# ignore m/1 testnets
|
|
|
|
if not (name.endswith("Testnet") and s == 1):
|
|
|
|
slip44[s].append(s)
|
|
|
|
|
|
|
|
# skip address types on cashaddr currencies
|
|
|
|
if coin["cashaddr_prefix"]:
|
|
|
|
continue
|
|
|
|
|
|
|
|
at_p2pkh[coin["address_type"]].append(name)
|
|
|
|
at_p2sh[coin["address_type_p2sh"]].append(name)
|
|
|
|
|
|
|
|
def prune(d):
|
|
|
|
ret = d.copy()
|
|
|
|
for key in d.keys():
|
|
|
|
if len(d[key]) < 2:
|
|
|
|
del ret[key]
|
|
|
|
return ret
|
|
|
|
|
|
|
|
return dict(
|
|
|
|
slip44=prune(slip44),
|
|
|
|
address_type=prune(at_p2pkh),
|
|
|
|
address_type_p2sh=prune(at_p2sh),
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2018-07-23 14:33:00 +00:00
|
|
|
def _ensure_mandatory_values(coins):
|
|
|
|
"""Checks that every coin has the mandatory fields: name, shortcut, key"""
|
2018-07-20 14:49:45 +00:00
|
|
|
for coin in coins:
|
|
|
|
if not all(coin.get(k) for k in ("name", "shortcut", "key")):
|
|
|
|
raise ValueError(coin)
|
|
|
|
|
|
|
|
|
2018-07-23 14:33:00 +00:00
|
|
|
def _filter_duplicate_shortcuts(coins):
|
|
|
|
"""Removes coins with identical `shortcut`s.
|
|
|
|
This is used to drop colliding ERC20 tokens.
|
|
|
|
"""
|
2018-07-20 14:49:45 +00:00
|
|
|
dup_keys = set()
|
|
|
|
retained_coins = OrderedDict()
|
|
|
|
|
|
|
|
for coin in coins:
|
|
|
|
key = coin["shortcut"]
|
|
|
|
if key in dup_keys:
|
|
|
|
pass
|
|
|
|
elif key in retained_coins:
|
|
|
|
dup_keys.add(key)
|
|
|
|
del retained_coins[key]
|
|
|
|
else:
|
|
|
|
retained_coins[key] = coin
|
|
|
|
|
|
|
|
# modify original list
|
|
|
|
coins[:] = retained_coins.values()
|
|
|
|
# return duplicates
|
|
|
|
return dup_keys
|
|
|
|
|
|
|
|
|
|
|
|
def _btc_sort_key(coin):
|
|
|
|
if coin["name"] in ("Bitcoin", "Testnet"):
|
|
|
|
return "000000" + coin["name"]
|
|
|
|
else:
|
|
|
|
return coin["name"]
|
|
|
|
|
|
|
|
|
|
|
|
def get_all():
|
2018-07-23 14:33:00 +00:00
|
|
|
"""Returns all definition as dict organized by coin type.
|
|
|
|
`coins` for btc-like coins,
|
|
|
|
`eth` for ethereum networks,
|
|
|
|
`erc20` for ERC20 tokens,
|
|
|
|
`nem` for NEM mosaics,
|
|
|
|
`misc` for other networks.
|
|
|
|
"""
|
2018-07-20 14:49:45 +00:00
|
|
|
all_coins = dict(
|
2018-07-23 14:33:00 +00:00
|
|
|
coins=_load_btc_coins(),
|
|
|
|
eth=_load_ethereum_networks(),
|
|
|
|
erc20=_load_erc20_tokens(),
|
|
|
|
nem=_load_nem_mosaics(),
|
|
|
|
misc=_load_misc(),
|
2018-07-20 14:49:45 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
for k, coins in all_coins.items():
|
2018-07-23 14:33:00 +00:00
|
|
|
if k == "coins":
|
2018-07-20 14:49:45 +00:00
|
|
|
coins.sort(key=_btc_sort_key)
|
2018-07-23 14:33:00 +00:00
|
|
|
elif k == "nem":
|
|
|
|
# do not sort nem
|
|
|
|
pass
|
2018-07-20 14:49:45 +00:00
|
|
|
else:
|
|
|
|
coins.sort(key=lambda c: c["key"].upper())
|
|
|
|
|
2018-07-23 14:33:00 +00:00
|
|
|
_ensure_mandatory_values(coins)
|
2018-07-20 14:49:45 +00:00
|
|
|
if k != "eth":
|
2018-07-23 14:33:00 +00:00
|
|
|
dup_keys = _filter_duplicate_shortcuts(coins)
|
2018-07-20 14:49:45 +00:00
|
|
|
if dup_keys:
|
|
|
|
log.warning(
|
|
|
|
"{}: removing duplicate symbols: {}".format(k, ", ".join(dup_keys))
|
|
|
|
)
|
|
|
|
|
|
|
|
return all_coins
|
|
|
|
|
|
|
|
|
|
|
|
def get_list():
|
2018-07-23 14:33:00 +00:00
|
|
|
"""Return all definitions as a single list of coins."""
|
2018-07-20 14:49:45 +00:00
|
|
|
all_coins = get_all()
|
|
|
|
return sum(all_coins.values(), [])
|
|
|
|
|
|
|
|
|
|
|
|
def get_dict():
|
2018-07-23 14:33:00 +00:00
|
|
|
"""Return all definitions as a dict indexed by coin keys."""
|
2018-07-20 14:49:45 +00:00
|
|
|
return {coin["key"]: coin for coin in get_list()}
|