commit 0d4a8777e65215c5f63d6f0ac06c34de2010114b Author: Daniel Edgecumbe Date: Wed Sep 27 06:30:09 2017 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a16313 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pyc +__pycache__ +*.swp +*.log +*.conf +.ropeproject diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..207a600 --- /dev/null +++ b/COPYING @@ -0,0 +1,19 @@ +Copyright (c) 2014-2017 esotericnonsense (Daniel Edgecumbe) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..93a8ff0 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# bitcoind-ncurses2 v0.2.0-dev + +## This repository is under heavy development. Safety not guaranteed. + +Python ncurses front-end for bitcoind. Uses the JSON-RPC API. + +![ScreenShot](/img/bitcoind-ncurses2.gif) + +- esotericnonsense (Daniel Edgecumbe) + +## Dependencies + +* Developed with python 3.6.2, Bitcoin Core 0.15.0.1 +* PyPi packages: aiohttp and async-timeout (see requirements.txt) + +## Features + +* Updating monitor mode showing bitcoind's status, including: +* Current block information: hash, height, fees, timestamp, age, diff, ... +* Wallet balance +* Total bandwidth used (up / down) +* Connected peers, IP addresses, user agents, ... + +## Installation and usage + +``` +git clone https://github.com/esotericnonsense/bitcoind-ncurses2 +``` + +``` +pip install -r bitcoind-ncurses2/requirements.txt +``` +or, on Arch Linux: +``` +pacman -S python-aiohttp python-async-timeout +``` + +``` +cd bitcoind-ncurses2 +python3 main.py +``` + +bitcoind-ncurses2 will automatically use the cookie file available in +~/.bitcoin/, or the RPC settings in ~/.bitcoin/bitcoin.conf. To use a different +datadir, specify the --datadir flag: + +``` +python3 main.py --datadir /some/path/to/your/datadir +``` + +This is an early development release and a complete rewrite of the original +bitcoind-ncurses. Expect the unexpected. + +Donations +--------- + +If you have found bitcoind-ncurses2 useful, please consider donating. + +All funds go towards the operating costs of my hardware and future +Bitcoin development projects. + +![ScreenShot](/img/3BYFucUnVNhZjUDf6tZweuZ5r9PPjPEcRv.png) + +**bitcoin 3BYFucUnVNhZjUDf6tZweuZ5r9PPjPEcRv** diff --git a/config.py b/config.py new file mode 100644 index 0000000..783e429 --- /dev/null +++ b/config.py @@ -0,0 +1,20 @@ +# Copyright (c) 2014-2017 esotericnonsense (Daniel Edgecumbe) +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/licenses/mit-license.php + + +def parse_file(filename): + with open(filename, "r") as f: + cfg = {} + for line in f: + line = line.strip() + if line and not line.startswith("#"): + try: + # replace maintains compatibility with older config files + (key, value) = line.replace(' = ', '=').split('=', 1) + cfg[key] = value + except ValueError: + # Happens when line has no '=', ignore + pass + + return cfg diff --git a/footer.py b/footer.py new file mode 100644 index 0000000..5cdc816 --- /dev/null +++ b/footer.py @@ -0,0 +1,65 @@ +# Copyright (c) 2014-2017 esotericnonsense (Daniel Edgecumbe) +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/licenses/mit-license.php + +import curses + +from macros import MODES + + +class FooterView(object): + def __init__(self): + self._pad = None + + self._mode = None + self._dt = None + + self._callbacks = set() + + def add_callback(self, callback): + self._callbacks.add(callback) + + def draw(self): + # TODO: figure out window width etc. + if self._pad is None: + self._pad = curses.newpad(2, 100) + else: + self._pad.clear() + + CYELLOW = curses.color_pair(5) + CREVERSE = curses.A_REVERSE + + x = 1 + for mode_string in MODES: + first_char, rest = mode_string[0], mode_string[1:] + modifier = curses.A_BOLD + if self._mode == mode_string: + # first_char = first_char.upper() + modifier += CREVERSE + self._pad.addstr(0, x, first_char, modifier + CYELLOW) + self._pad.addstr(0, x+1, rest, modifier) + x += len(mode_string) + 4 + + if self._dt: + self._pad.addstr(0, 81, self._dt.isoformat(timespec="seconds")[:19]) + + self._pad.refresh(0, 0, 25, 0, 27, 100) + + async def on_mode_change(self, newmode, seek=None): + if seek is not None: + assert newmode is None + if self._mode is None: + return + idx = MODES.index(self._mode) + idx = (idx + seek) % len(MODES) + newmode = MODES[idx] + + self._mode = newmode + self.draw() + + for callback in self._callbacks: + await callback(newmode) + + async def on_tick(self, dt): + self._dt = dt + self.draw() diff --git a/header.py b/header.py new file mode 100644 index 0000000..84ffa1c --- /dev/null +++ b/header.py @@ -0,0 +1,151 @@ +# Copyright (c) 2014-2017 esotericnonsense (Daniel Edgecumbe) +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/licenses/mit-license.php + +import curses +import platform + +from macros import VERSION_STRING + + +class HeaderView(object): + def __init__(self): + # one larger than we will ever draw otherwise we can't populate + # the bottom-right + self._pad = curses.newpad(2, 101) + + self._platform = "{} {} {}".format( + platform.system(), + platform.release(), + platform.machine(), + ) + + self._subversion = None + self._chain = None + self._connectioncount = None + self._nettotals = None + self._balance = None + + def draw(self): + # TODO: figure out window width etc. + + self._pad.clear() + + CGREEN = curses.color_pair(1) + CCYAN = curses.color_pair(2) + CRED = curses.color_pair(3) + CYELLOW = curses.color_pair(5) + CBOLD = curses.A_BOLD + + colors = { + "main": CGREEN + CBOLD, + "test": CCYAN + CBOLD, + "regtest": CRED + CBOLD, + } + currencies = { + "main": "BTC", + "test": "tBC", + "regtest": "rBC", + } + version_color = colors.get(self._chain, CBOLD) + currency = currencies.get(self._chain, "???") + chn = self._chain if self._chain is not None else "???" + + self._pad.addstr(0, 1, "{} ({})".format( + VERSION_STRING[:30], + chn + ), version_color) + + if self._connectioncount is not None: + if self._connectioncount > 8: + peercolor = CGREEN + CBOLD + elif self._connectioncount > 0: + peercolor = CBOLD + else: + peercolor = CRED + CBOLD + + self._pad.addstr(0, 37, "{: 4d} {}".format( + self._connectioncount, + "peers" if self._connectioncount != 1 else "peer" + ), peercolor) + + if self._subversion: + self._pad.addstr(1, 1, "{} / {}".format( + self._platform[:27], + self._subversion.strip("/").strip(":")[:18] + ), version_color) + + if self._nettotals is not None: + self._pad.addstr(0, 51, "Up: {: 9.2f} MB".format( + self._nettotals[1] / 1048576, + ), CBOLD + CCYAN) + self._pad.addstr(1, 51, "Down: {: 9.2f} MB".format( + self._nettotals[0] / 1048576, + ), CBOLD + CGREEN) + + if self._balance is not None: + self._pad.addstr(0, 82, "{: 14.8f} {}".format( + self._balance[0], + currency + ), CBOLD) + + # We only show unconfirmed if we have both unc/imm. So it goes. + if self._balance[1] != 0: + self._pad.addstr(1, 69, "unconfirmed: {: 14.8f} {}".format( + self._balance[1], + currency, + ), CBOLD + CYELLOW) + elif self._balance[2] != 0: + self._pad.addstr(1, 72, "immature: {: 14.8f} {}".format( + self._balance[2], + currency, + ), CBOLD + CRED) + else: + self._pad.addstr(0, 74, "wallet disabled") + + self._pad.refresh(0, 0, 1, 0, 2, 100) + + async def on_networkinfo(self, key, obj): + try: + self._subversion = obj["result"]["subversion"] + except KeyError: + pass + + self.draw() + + async def on_blockchaininfo(self, key, obj): + try: + self._chain = obj["result"]["chain"] + except KeyError: + pass + + self.draw() + + async def on_peerinfo(self, key, obj): + try: + self._connectioncount = len(obj["result"]) + except KeyError: + pass + + self.draw() + + async def on_nettotals(self, key, obj): + try: + tbr = obj["result"]["totalbytesrecv"] + tbs = obj["result"]["totalbytessent"] + self._nettotals = (tbr, tbs) + except KeyError: + pass + + self.draw() + + async def on_walletinfo(self, key, obj): + try: + bal = obj["result"]["balance"] + ubal = obj["result"]["unconfirmed_balance"] + ibal = obj["result"]["immature_balance"] + self._balance = (bal, ubal, ibal) + except KeyError: + pass + + self.draw() diff --git a/img/3BYFucUnVNhZjUDf6tZweuZ5r9PPjPEcRv.png b/img/3BYFucUnVNhZjUDf6tZweuZ5r9PPjPEcRv.png new file mode 100644 index 0000000..7b47709 Binary files /dev/null and b/img/3BYFucUnVNhZjUDf6tZweuZ5r9PPjPEcRv.png differ diff --git a/img/bitcoind-ncurses2.gif b/img/bitcoind-ncurses2.gif new file mode 100644 index 0000000..4264ca6 Binary files /dev/null and b/img/bitcoind-ncurses2.gif differ diff --git a/img/monitor.png b/img/monitor.png new file mode 100644 index 0000000..fc5aece Binary files /dev/null and b/img/monitor.png differ diff --git a/img/peers.png b/img/peers.png new file mode 100644 index 0000000..9d2689a Binary files /dev/null and b/img/peers.png differ diff --git a/interface.py b/interface.py new file mode 100644 index 0000000..428849f --- /dev/null +++ b/interface.py @@ -0,0 +1,28 @@ +# Copyright (c) 2014-2017 esotericnonsense (Daniel Edgecumbe) +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/licenses/mit-license.php + +import curses + + +def init_curses(): + window = curses.initscr() + curses.noecho() + curses.curs_set(0) + + curses.start_color() + curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK) + curses.init_pair(2, curses.COLOR_CYAN, curses.COLOR_BLACK) + curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK) + curses.init_pair(4, curses.COLOR_MAGENTA, curses.COLOR_BLACK) + curses.init_pair(5, curses.COLOR_YELLOW, curses.COLOR_BLACK) + + window.timeout(50) + window.keypad(1) + + return window + + +def end_curses(): + curses.nocbreak() + curses.endwin() diff --git a/macros.py b/macros.py new file mode 100644 index 0000000..0fdf0a8 --- /dev/null +++ b/macros.py @@ -0,0 +1,12 @@ +# Copyright (c) 2014-2017 esotericnonsense (Daniel Edgecumbe) +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/licenses/mit-license.php + +VERSION_STRING = "bitcoind-ncurses v0.2.0-dev" + +# MODES = [ +# "monitor", "wallet", "peers", "block", +# "tx", "console", "net", "forks", +# ] +MODES = ["monitor", "peers"] +DEFAULT_MODE = "monitor" diff --git a/main.py b/main.py new file mode 100644 index 0000000..219919b --- /dev/null +++ b/main.py @@ -0,0 +1,169 @@ +# Copyright (c) 2014-2017 esotericnonsense (Daniel Edgecumbe) +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/licenses/mit-license.php + +import argparse +import os +import asyncio +import datetime + +import rpc +import interface +import header +import footer +import monitor +import peers + +from macros import MODES, DEFAULT_MODE + + +async def handle_hotkeys(window, callback): + + async def handle_key(key): + if key == "KEY_LEFT": + await callback(None, seek=-1) + return + + if key == "KEY_RIGHT": + await callback(None, seek=1) + return + + if len(key) > 1: + return + + lower = key.lower() + + for mode in MODES: + if mode[0] == lower: + await callback(mode) + + first = True + while True: + # This is basically spinning which is really annoying. + # TODO: find a way of having async blocking getch/getkey. + try: + key = window.getkey() + except Exception: + # This is bonkers and I don't understand it. + if first: + await callback(DEFAULT_MODE) + first = False + + await asyncio.sleep(0.05) + continue + + await handle_key(key) + + +async def poll_client(client, method, callback, sleeptime): + # Allow the rest of the program to start. + await asyncio.sleep(0.1) + + while True: + j = await client.request(method) + await callback(method, j) + await asyncio.sleep(sleeptime) + + +async def tick(callback, sleeptime): + # Allow the rest of the program to start. + await asyncio.sleep(0.1) + + while True: + dt = datetime.datetime.utcnow() + await callback(dt) + await asyncio.sleep(sleeptime) + + +def initialize(): + # parse commandline arguments + parser = argparse.ArgumentParser() + parser.add_argument("--datadir", + help="path to bitcoin datadir [~/.bitcoin/]", + default=os.path.expanduser("~/.bitcoin/")) + args = parser.parse_args() + + url = rpc.get_url_from_datadir(args.datadir) + auth = rpc.get_auth_from_datadir(args.datadir) + client = rpc.BitcoinRPCClient(url, auth) + + return client + + +def check_disablewallet(client): + """ Check if the wallet is enabled. """ + + # Ugly, a synchronous RPC request mechanism would be nice here. + x = asyncio.gather(client.request("getwalletinfo")) + loop = asyncio.get_event_loop() + loop.run_until_complete(x) + + try: + x.result()[0]["result"]["walletname"] + except (KeyError, TypeError): + return True + + return False + + +def create_tasks(client, window): + headerview = header.HeaderView() + footerview = footer.FooterView() + + monitorview = monitor.MonitorView(client) + peerview = peers.PeersView() + footerview.add_callback(monitorview.on_mode_change) + footerview.add_callback(peerview.on_mode_change) + + async def on_peerinfo(key, obj): + await headerview.on_peerinfo(key, obj) + await peerview.on_peerinfo(key, obj) + + async def on_tick(dt): + await footerview.on_tick(dt) + await monitorview.on_tick(dt) + + tasks = [ + poll_client(client, "getbestblockhash", + monitorview.on_bestblockhash, 1.0), + poll_client(client, "getblockchaininfo", + headerview.on_blockchaininfo, 5.0), + poll_client(client, "getnetworkinfo", + headerview.on_networkinfo, 5.0), + poll_client(client, "getnettotals", + headerview.on_nettotals, 5.0), + poll_client(client, "getpeerinfo", + on_peerinfo, 5.0), + tick(on_tick, 1.0), + handle_hotkeys(window, footerview.on_mode_change) + ] + + if not check_disablewallet(client): + tasks.append( + poll_client(client, "getwalletinfo", headerview.on_walletinfo, 1.0) + ) + + return tasks + + +def mainfn(): + client = initialize() + + try: + window = interface.init_curses() + tasks = create_tasks(client, window) + + loop = asyncio.get_event_loop() + t = asyncio.gather(*tasks) + loop.run_until_complete(t) + + finally: + try: + loop.close() + except BaseException: + pass + interface.end_curses() + + +if __name__ == "__main__": + mainfn() diff --git a/monitor.py b/monitor.py new file mode 100644 index 0000000..f46d6ca --- /dev/null +++ b/monitor.py @@ -0,0 +1,155 @@ +# Copyright (c) 2014-2017 esotericnonsense (Daniel Edgecumbe) +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/licenses/mit-license.php + +import datetime +import math +import curses +import asyncio +from decimal import Decimal + + +class MonitorView(object): + def __init__(self, client): + self._client = client + + self._pad = None + + self._visible = False + + self._lock = asyncio.Lock() + self._bestblockhash = None + self._bestblockheader = None # raw json blockheader + self._bestblock = None # raw json block + self._bestcoinbase = None # raw json tx + self._dt = None + + def _draw(self): + # TODO: figure out window width etc. + + if self._pad is not None: + self._pad.clear() + else: + self._pad = curses.newpad(20, 100) + + if self._bestblockheader: + bbh = self._bestblockheader + self._pad.addstr(0, 1, "Height: {: 8d}".format(bbh["height"])) + self._pad.addstr(0, 36, bbh["hash"]) + + if self._bestblock: + bb = self._bestblock + self._pad.addstr(1, 1, "Size: {: 8d} bytes Weight: {: 8d} WU".format( + bb["size"], + bb["weight"] + )) + + self._pad.addstr(1, 64, "Block timestamp: {}".format( + datetime.datetime.utcfromtimestamp(bb["time"]), + )) + + if self._dt: + stampdelta = int( + (self._dt - datetime.datetime.utcfromtimestamp(bb["time"])) + .total_seconds()) + + if stampdelta > 3600*3: # probably syncing + stampdelta_string = " (syncing)" + + elif stampdelta > 0: + m, s = divmod(stampdelta, 60) + h, m = divmod(m, 60) + d, h = divmod(h, 24) + stampdelta_string = "({:d}d {:02d}:{:02d}:{:02d} by stamp)".format(d,h,m,s) + + else: + stampdelta_string = " (stamp in future)" + + self._pad.addstr(2, 64, "Age: {}".format( + stampdelta_string)) + + self._pad.addstr(2, 1, "Transactions: {} ({} bytes/tx, {} WU/tx)".format( + len(bb["tx"]), + bb["size"] // len(bb["tx"]), + bb["weight"] // len(bb["tx"]), + )) + + if self._bestcoinbase: + bcb = self._bestcoinbase + reward = sum(vout["value"] for vout in bcb["vout"]) + + # TODO: if chain is regtest, this is different + halvings = bb["height"] // 210000 + block_subsidy = Decimal(50 * (0.5 ** halvings)) + + total_fees = Decimal(reward) - block_subsidy + + self._pad.addstr(4, 1, "Block reward: {:.6f} BTC".format( + reward)) + + if len(bb["tx"]) > 1: + if reward > 0: + fee_pct = total_fees * 100 / Decimal(reward) + else: + fee_pct = 0 + mbtc_per_tx = (total_fees / (len(bb["tx"]) - 1)) * 1000 + + # 80 bytes for the block header. + total_tx_size = bb["size"] - 80 - bcb["size"] + if total_tx_size > 0: + sat_per_kb = ((total_fees * 1024) / total_tx_size) * 100000000 + else: + sat_per_kb = 0 + self._pad.addstr(4, 34, "Fees: {: 8.6f} BTC ({: 6.2f}%, avg {: 6.2f} mBTC/tx, ~{: 7.0f} sat/kB)".format(total_fees, fee_pct, mbtc_per_tx, sat_per_kb)) + + self._pad.addstr(6, 1, "Diff: {:,}".format( + int(bb["difficulty"]), + )) + self._pad.addstr(7, 1, "Chain work: 2**{:.6f}".format( + math.log(int(bb["chainwork"], 16), 2), + )) + + self._pad.refresh(0, 0, 4, 0, 24, 100) + + async def draw(self): + with await self._lock: + self._draw() + + async def on_bestblockhash(self, key, obj): + try: + bestblockhash = obj["result"] + except KeyError: + return + + draw = False + with await self._lock: + if bestblockhash != self._bestblockhash: + draw = True + self._bestblockhash = bestblockhash + + j = await self._client.request("getblockheader", [bestblockhash]) + self._bestblockheader = j["result"] + + j = await self._client.request("getblock", [bestblockhash]) + self._bestblock = j["result"] + + j = await self._client.request("getrawtransaction", [j["result"]["tx"][0], 1]) + self._bestcoinbase = j["result"] + + if draw and self._visible: + await self.draw() + + async def on_tick(self, dt): + with await self._lock: + self._dt = dt + + if self._visible: + await self.draw() + + async def on_mode_change(self, newmode): + if newmode != "monitor": + self._visible = False + return + + self._visible = True + await self.draw() diff --git a/peers.py b/peers.py new file mode 100644 index 0000000..2cee7e9 --- /dev/null +++ b/peers.py @@ -0,0 +1,105 @@ +# Copyright (c) 2014-2017 esotericnonsense (Daniel Edgecumbe) +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/licenses/mit-license.php + +import datetime +import time +import math +import curses +import asyncio + + +class PeersView(object): + def __init__(self): + self._pad = None + self._visible = False + self._peerinfo = None # raw data from getpeerinfo + + def draw(self): + # TODO: figure out window width etc. + + if self._pad is not None: + self._pad.clear() + else: + self._pad = curses.newpad(20, 100) # y, x + + if self._peerinfo: + po = self._peerinfo + + self._pad.addstr(0, 1, "Node IP Version Recv Sent Time Height", curses.A_BOLD + curses.color_pair(5)) + + window_height = 20 + offset = 0 + for index in range(offset, offset+window_height): + if index < len(po): + peer = po[index] + + condition = (index == offset+window_height-1) and (index+1 < len(state['peerinfo'])) + condition = condition or ( (index == offset) and (index > 0) ) + + if condition: + # scrolling up or down is possible + self._pad.addstr(1+index-offset, 3, "...") + + else: + if peer['inbound']: + self._pad.addstr(1+index-offset, 1, 'I') + + elif 'syncnode' in peer: + if peer['syncnode']: + # syncnodes are outgoing only + self._pad.addstr(1+index-offset, 1, 'S') + + addr_str = peer['addr'].replace(".onion","").replace(":8333","").replace(": 18333","").strip("[").strip("]") + + # truncate long ip addresses (ipv6) + addr_str = (addr_str[:17] + '...') if len(addr_str) > 20 else addr_str + + self._pad.addstr(1+index-offset, 1, addr_str) + self._pad.addstr(1+index-offset, 22, + peer['subver'][1:40][:-1] + ) + + mbrecv = "% 7.1f" % ( float(peer['bytesrecv']) / 1048576 ) + mbsent = "% 7.1f" % ( float(peer['bytessent']) / 1048576 ) + + self._pad.addstr(1+index-offset, 60, mbrecv + 'MB') + self._pad.addstr(1+index-offset, 70, mbsent + 'MB') + + timedelta = int(time.time() - peer['conntime']) + m, s = divmod(timedelta, 60) + h, m = divmod(m, 60) + d, h = divmod(h, 24) + + time_string = "" + if d: + time_string += ("%d" % d + "d").rjust(3) + " " + time_string += "%02d" % h + ":" + elif h: + time_string += "%02d" % h + ":" + time_string += "%02d" % m + ":" + time_string += "%02d" % s + + self._pad.addstr(1+index-offset, 79, time_string.rjust(12)) + + if 'synced_headers' in peer: + self._pad.addstr(1+index-offset, 93, str(peer['synced_headers']).rjust(7) ) + + self._pad.refresh(0, 0, 4, 0, 24, 100) + + async def on_peerinfo(self, key, obj): + try: + self._peerinfo = obj["result"] + except KeyError: + return + + if self._visible: + self.draw() + + async def on_mode_change(self, newmode): + if newmode != "peers": + self._visible = False + return + + self._visible = True + self.draw() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ae3274a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiohttp>=2.2.5 +async-timeout>=1.4.0 diff --git a/rpc.py b/rpc.py new file mode 100644 index 0000000..07b1f19 --- /dev/null +++ b/rpc.py @@ -0,0 +1,113 @@ +# Copyright (c) 2014-2017 esotericnonsense (Daniel Edgecumbe) +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/licenses/mit-license.php + +import aiohttp +import async_timeout +import base64 +import os + +import ujson as json + +import config + + +def craft_url(proto, ip, port): + return "{}://{}:{}".format(proto, ip, port) + + +def get_url_from_datadir(datadir): + configfile = os.path.join(datadir, "bitcoin.conf") + + try: + cfg = config.parse_file(configfile) + except IOError: + return craft_url("http", "localhost", 8332) + + proto = cfg["protocol"] if "protocol" in cfg else "http" + ip = cfg["rpcip"] if "rpcip" in cfg else "localhost" + try: + port = cfg["rpcport"] + except KeyError: + # If both regtest and testnet are set, bitcoind will not run. + if "regtest" in cfg and cfg["regtest"] == "1": + port = 18332 + elif "testnet" in cfg and cfg["testnet"] == "1": + port = 18332 + else: + port = 8332 + + return craft_url(proto, ip, port) + + +def get_auth_from_datadir(datadir): + def craft_auth_from_credentials(user, password): + details = ":".join([user, password]) + return base64.b64encode(bytes(details, "utf-8")).decode("utf-8") + + def get_auth_from_cookiefile(cookiefile): + # Raises IOError if file does not exist + with open(cookiefile, "r") as f: + return base64.b64encode(bytes(f.readline(), "utf-8")).decode("utf-8") + + cookiefile = os.path.join(datadir, ".cookie") + + try: + return get_auth_from_cookiefile(cookiefile) + except FileNotFoundError: + print("cookiefile not found, falling back to password authentication") + # Fall back to credential-based authentication + configfile = os.path.join(datadir, "bitcoin.conf") + + try: + cfg = config.parse_file(configfile) + except IOError: + print("configuration file not found; aborting.") + raise + + try: + rpcuser = cfg["rpcuser"] + rpcpassword = cfg["rpcpassword"] + except KeyError: + if not ("rpcuser" in cfg): + print("rpcuser not in configuration file.") + if not ("rpcpassword" in cfg): + print("rpcpassword not in configuration file.") + raise + + return craft_auth_from_credentials(rpcuser, rpcpassword) + + +class BitcoinRPCClient(object): + def __init__(self, url, auth): + self._url = url + self._headers = { + "Authorization": "Basic {}".format(auth), + "Content-Type": "text/plain", + } + + @staticmethod + async def _craft_request(req, params, ident): + d = { + # "jsonrpc": "2.0", # Currently ignored by Bitcoin Core. + "method": req, + } + + if params is not None: + d["params"] = params + + if ident is not None: + d["id"] = ident + + return json.dumps(d) + + async def _fetch(self, session, req): + with async_timeout.timeout(5): + async with session.post(self._url, headers=self._headers, data=req) as response: + return await response.text() + + async def request(self, method, params=None, ident=None, callback=None): + async with aiohttp.ClientSession() as session: + req = await self._craft_request(method, params, ident) + html = await self._fetch(session, req) + return json.loads(html)