diff --git a/common/tools/README.md b/common/tools/README.md index ede0c7280..10b51b4af 100644 --- a/common/tools/README.md +++ b/common/tools/README.md @@ -85,6 +85,15 @@ See docstrings for the most important functions: `coin_info()` and `support_info The file `coindef.py` is a protobuf definition for passing coin data to Trezor from the outside. +### `marketcap.py` + +Module for obtaining market cap and price data used by `coins_details.py` and `maxfee.py`. + +### `maxfee.py` + +Updates the `maxfee_kb` coin property based on a specified maximum per-transaction fee. The command +fetches current price data from https://coinmarketcap.com/ to convert from fiat-denominated maximum +fee. # Release Workflow diff --git a/common/tools/marketcap.py b/common/tools/marketcap.py index fed82e0db..e28840b98 100644 --- a/common/tools/marketcap.py +++ b/common/tools/marketcap.py @@ -10,6 +10,7 @@ COINMAKETCAP_CACHE = os.path.join(os.path.dirname(__file__), "coinmarketcap.json COINMARKETCAP_API_BASE = "https://pro-api.coinmarketcap.com/v1/" MARKET_CAPS = {} +PRICES = {} def call(endpoint, api_key, params=None): @@ -20,7 +21,7 @@ def call(endpoint, api_key, params=None): def init(api_key, refresh=None): - global MARKET_CAPS + global MARKET_CAPS, PRICES force_refresh = refresh is True disable_refresh = refresh is False @@ -59,20 +60,24 @@ def init(api_key, refresh=None): except Exception as e: raise RuntimeError("market cap data unavailable") from e - coin_data = {} + cap_data = {} + price_data = {} for coin in coinmarketcap_data["data"]: slug = coin["slug"] + symbol = coin["symbol"] platform = coin["meta"]["platform"] market_cap = coin["quote"]["USD"]["market_cap"] + price = coin["quote"]["USD"]["price"] if market_cap is not None: - coin_data[slug] = int(market_cap) + cap_data[slug] = int(market_cap) + price_data[symbol] = price if platform is not None and platform["name"] == "Ethereum": address = platform["token_address"].lower() - coin_data[address] = int(market_cap) + cap_data[address] = int(market_cap) + price_data[symbol] = price - MARKET_CAPS = coin_data - - return coin_data + MARKET_CAPS = cap_data + PRICES = price_data def marketcap(coin): @@ -89,3 +94,7 @@ def marketcap(coin): if cap is None: cap = MARKET_CAPS.get(coin["shortcut"].lower()) return cap + + +def fiat_price(coin_symbol): + return PRICES.get(coin_symbol) diff --git a/common/tools/maxfee.py b/common/tools/maxfee.py new file mode 100644 index 000000000..b1df0830a --- /dev/null +++ b/common/tools/maxfee.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Updates maxfee_kb in given JSON coin definitions.""" +import glob +import json +import logging +import math +import os.path +import re +import sys + +import click + +import coin_info +import marketcap + +DEFAULT_SKIP_RE = ( + r"^Bitcoin$", + r"^Regtest$", + r"Testnet", +) +MAX_DELTA_PERCENT = 25 + + +def round_sats(sats, precision=1): + """ + Truncates `sats` down to a number with more trailing zeros. + + The result is comprised of `precision`+1 of leading digits followed by rest of zeros. + + >>> round_sats(123456789, precision=2) + 123000000 + >>> round_sats(123456789, precision=0) + 100000000 + """ + exp = math.floor(math.log10(sats)) + div = 10 ** (exp - precision) + return sats // div * div + + +def compute_maxfee(maxcost, price, txsize): + coins_per_tx = maxcost / price + sats_per_tx = coins_per_tx * 10 ** 8 + tx_per_kb = 1024.0 / txsize + sats_per_kb = sats_per_tx * tx_per_kb + return int(sats_per_kb) + + +def delta_percent(old, new): + return int(abs(new - old) / old * 100.0) + + +def setup_logging(verbose): + log_level = logging.DEBUG if verbose else logging.WARNING + root = logging.getLogger() + root.setLevel(log_level) + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(log_level) + root.addHandler(handler) + + +@click.command() +# fmt: off +@click.argument("filename", nargs=-1, type=click.Path(writable=True)) +@click.option("-m", "--cost", type=float, default=10.0, show_default=True, help="Maximum transaction fee in USD") +@click.option("-s", "--txsize", type=int, default=250, show_default=True, help="Transaction size in bytes") +@click.option("-S", "--skip", type=str, multiple=True, help="Regex of coin name to skip, can be used multiple times") +@click.option("-r", "--refresh", "refresh", flag_value=True, default=None, help="Force refresh market cap info") +@click.option("-R", "--no-refresh", "refresh", flag_value=False, default=None, help="Force use cached market cap info") +@click.option("-A", "--api-key", required=True, envvar="COINMARKETCAP_API_KEY", help="Coinmarketcap API key") +@click.option("-v", "--verbose", is_flag=True, help="Display more info") +# fmt: on +def main(filename, cost, skip, txsize, refresh, api_key, verbose): + """ + Updates maxfee_kb in JSON coin definitions. + + The new value is calculated from the --cost argument which specifies maximum + transaction fee in fiat denomination. The fee is converted to coin value + using current price data. Then per-kilobyte fee is computed using given + transaction size. + + If no filenames are provided, all definitions except Bitcoin and testnets + are updated. + """ + setup_logging(verbose) + marketcap.init(api_key, refresh=refresh) + + if len(filename) > 0: + coin_files = filename + else: + coin_files = glob.glob(os.path.join(coin_info.DEFS_DIR, "bitcoin", "*.json")) + if len(skip) == 0: + skip = DEFAULT_SKIP_RE + + for filename in sorted(coin_files): + coin = coin_info.load_json(filename) + short = coin["coin_shortcut"] + + if any(re.search(s, coin["coin_name"]) is not None for s in skip): + logging.warning("{}:\tskipping because --skip matches".format(short)) + continue + + price = marketcap.fiat_price(short) + if price is None: + logging.error("{}:\tno price data, skipping".format(short)) + continue + + old_maxfee_kb = coin["maxfee_kb"] + new_maxfee_kb = round_sats(compute_maxfee(cost, price, txsize)) + if old_maxfee_kb != new_maxfee_kb: + coin["maxfee_kb"] = new_maxfee_kb + with open(filename, "w") as fh: + json.dump(coin, fh, indent=2) + fh.write("\n") + logging.info( + "{}:\tupdated {} -> {}".format(short, old_maxfee_kb, new_maxfee_kb) + ) + delta = delta_percent(old_maxfee_kb, new_maxfee_kb) + if delta > MAX_DELTA_PERCENT: + logging.warning("{}:\tprice has changed by {} %".format(short, delta)) + else: + logging.info("{}:\tno change".format(short)) + + +if __name__ == "__main__": + main()