From db5ca7f64a2ff6d4c59689c0fb8f7137f44f990a Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Tue, 15 Dec 2020 11:39:29 +1030 Subject: [PATCH] currencyrate: new plugin to do currency conversions. Signed-off-by: Rusty Russell --- currencyrate/Makefile | 7 ++ currencyrate/README.md | 41 ++++++++++++ currencyrate/currencyrate.py | 119 ++++++++++++++++++++++++++++++++++ currencyrate/requirements.txt | 5 ++ 4 files changed, 172 insertions(+) create mode 100644 currencyrate/Makefile create mode 100644 currencyrate/README.md create mode 100755 currencyrate/currencyrate.py create mode 100644 currencyrate/requirements.txt diff --git a/currencyrate/Makefile b/currencyrate/Makefile new file mode 100644 index 0000000..385d245 --- /dev/null +++ b/currencyrate/Makefile @@ -0,0 +1,7 @@ +#! /usr/bin/make + +check: + @# E501 line too long (N > 79 characters) + @# E731 do not assign a lambda expression, use a def + @# W503: line break before binary operator + @flake8 --ignore=E501,E731,W503 *.py diff --git a/currencyrate/README.md b/currencyrate/README.md new file mode 100644 index 0000000..b027189 --- /dev/null +++ b/currencyrate/README.md @@ -0,0 +1,41 @@ +# Currencyrate plugin + +This plugin provides Bitcoin currency conversion functions using various +different backends and taking the median. It caches results for an hour. + +## Installation + +For general plugin installation instructions see the repos main +[README.md](https://github.com/lightningd/plugins/blob/master/README.md#Installation) + +## Options: + +* --add-source: Add a source, of form NAME,URL,MEMBERS where URL and MEMBERS + can have `{currency}` and `{currency_lc}` to substitute for upper-case and + lower-case currency names. MEMBERS is how to deconstruct the result, for + example if the result is `{"USD": {"last_trade": 12456.79}}` then MEMBERS + would be "USD,last_trade". +* --disable-source: Disable the source with this name. + +## Commands + +`currencyrate` returns the number of msats per unit from every backend, eg: + +``` +$ lightning-cli currencyrate USD +{ + "localbitcoins": "5347227msat", + "bitstamp": "5577515msat", + "coingecko": "5579273msat", +} +``` + +`currencyconvert` converts the given amount and currency into msats, using the +median from the above results. eg: + +``` +$ lightning-cli currencyconvert 100 USD +{ + "msat": "515941800msat" +} +``` diff --git a/currencyrate/currencyrate.py b/currencyrate/currencyrate.py new file mode 100755 index 0000000..aa48c52 --- /dev/null +++ b/currencyrate/currencyrate.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +from pyln.client import Plugin +from collections import namedtuple +from pyln.client import Millisatoshi +from cachetools import cached, TTLCache +import requests +import statistics +import time + +plugin = Plugin() + +Source = namedtuple('Source', ['name', 'urlformat', 'replymembers']) + +sources = [ + # e.g. {"GBP": {"volume_btc": "24.36647424", "rates": {"last": "13667.63"}, "avg_1h": "13786.86", "avg_6h": "13723.65", "avg_12h": "13680.23", "avg_24h": "13739.56"}, "USD": {"volume_btc": "27.97517017", "rates": {"last": "18204.21"}, "avg_1h": "19349.46", "avg_6h": "18621.72", "avg_12h": "18642.28", "avg_24h": "18698.94"} + # ... + # "GTQ": {"volume_btc": "0.03756101", "rates": {"last": "148505.46"}, "avg_1h": "148505.46", "avg_6h": "162463.69", "avg_12h": "162003.10", "avg_24h": "162003.10"}, "DKK": {"volume_btc": "0.00339923", "rates": {"last": "139737.53"}, "avg_12h": "139737.53", "avg_24h": "139737.53"}, "HTG": {"volume_btc": "0.00024758", "rates": {"last": "2019549.24"}, "avg_6h": "2019549.24", "avg_12h": "2019549.24", "avg_24h": "2019549.24"}, "NAD": {"volume_btc": "0.00722222", "rates": {"last": "360000.11"}, "avg_12h": "360000.11", "avg_24h": "360000.11"}} + Source('localbitcoins', + 'https://localbitcoins.com/bitcoinaverage/ticker-all-currencies/', + ['{currency}', "avg_6h"]), + # e.g. {"high": "18502.56", "last": "17970.41", "timestamp": "1607650787", "bid": "17961.87", "vwap": "18223.42", "volume": "7055.63066541", "low": "17815.92", "ask": "17970.41", "open": "18250.30"} + Source('bitstamp', + 'https://www.bitstamp.net/api/v2/ticker/btc{currency_lc}/', + ['last']), + # e.g. {"bitcoin":{"usd":17885.84}} + Source('coingecko', + 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies={currency_lc}', + ['bitcoin', '{currency_lc}']), +] + + +def get_currencyrate(plugin, currency, req_template, response_members): + # NOTE: Bitstamp has a DNS/Proxy issues that can return 404 + # Workaround: retry up to 5 times with a delay + currency_lc = currency.lower() + url = req_template.format(currency_lc=currency_lc, currency=currency) + for _ in range(5): + r = requests.get(url, proxies=plugin.proxies) + if r.status_code != 200: + time.sleep(1) + continue + break + + if r.status_code != 200: + plugin.log(level='info', message='{}: bad response {}'.format(url, r.status_code)) + return None + + json = r.json() + for m in response_members: + expanded = m.format(currency_lc=currency_lc, currency=currency) + if expanded not in json: + plugin.log(level='debug', message='{}: {} not in {}'.format(url, expanded, json)) + return None + json = json[expanded] + + try: + return Millisatoshi(int(10**11 / float(json))) + except Exception: + plugin.log(level='info', message='{}: could not convert {} to msat'.format(url, json)) + return None + + +def set_proxies(plugin): + config = plugin.rpc.listconfigs() + if 'always-use-proxy' in config and config['always-use-proxy']: + paddr = config['proxy'] + # Default port in 9050 + if ':' not in paddr: + paddr += ':9050' + plugin.proxies = {'https': 'socks5h://' + paddr, + 'http': 'socks5h://' + paddr} + else: + plugin.proxies = None + + +# Don't grab these more than once per hour. +@cached(cache=TTLCache(maxsize=1024, ttl=3600)) +def get_rates(plugin, currency): + rates = {} + for s in sources: + r = get_currencyrate(plugin, currency, s.urlformat, s.replymembers) + if r is not None: + rates[s.name] = r + + return rates + + +@plugin.method("currencyrate") +def currencyrate(plugin, currency): + """Gets currency from given APIs.""" + + return get_rates(plugin, currency.upper()) + + +@plugin.method("currencyconvert") +def currencyconvert(plugin, amount, currency): + """Converts currency using given APIs.""" + val = statistics.median([m.millisatoshis for m in get_rates(plugin, currency.upper()).values()]) * float(amount) + return {"msat": Millisatoshi(round(val))} + + +@plugin.init() +def init(options, configuration, plugin): + set_proxies(plugin) + + if options['add-source'] != '': + parts = options['add-source'].split(',') + sources.append(Source(parts[0], parts[1], parts[2:])) + + if options['disable-source'] != '': + for s in sources[:]: + if s.name == options['disable-source']: + sources.remove(s) + + +# As a bad example: binance,https://api.binance.com/api/v3/ticker/price?symbol=BTC{currency}T,price +plugin.add_option(name='add-source', default='', description='Add source name,urlformat,resultmembers...') +plugin.add_option(name='disable-source', default='', description='Disable source by name') +plugin.run() diff --git a/currencyrate/requirements.txt b/currencyrate/requirements.txt new file mode 100644 index 0000000..09cb306 --- /dev/null +++ b/currencyrate/requirements.txt @@ -0,0 +1,5 @@ +pyln-client>=0.7.3 +requests>=2.10.0 +requests[socks]>=2.10.0 +packaging>=14.1 +cachetools