def test_cached(self): fiat = Fiat() # Import cache fiat._cache = self.CACHE self.assertEqual(fiat.get_rate(), self.CACHE['eur'][0]) self.assertEqual(fiat.to_fiat(1), 0) self.assertEqual(fiat.to_fiat(0.001 * 1e8), 0.001 * self.CACHE['eur'][0]) self.assertEqual(fiat.to_satoshis(5), int(5 / self.CACHE['eur'][0] * 1e8)) self.assertRegex(fiat.to_fiat_str(1), '^\d*\.\d{2} €') self.assertRegex(fiat.to_fiat_str(7), '^\d*\.\d{2} €') self.assertRegex(fiat.to_fiat_str(1000), '^\d*\.\d{2} €')
class Lncli: """Interface to lncli command""" CMD = 'lncli' def __init__(self): self._cmd = [self.CMD] self.fiat = Fiat() if 'LNDDIR' in environ: self._cmd.append('--lnddir') self._cmd.append(environ['LNDDIR']) self._1ml = True self._lightblock = True self.aliases = {} self._cities = None self._update_lock = threading.Lock() self._updated = 0 self.update_aliases() def _command(self, *cmd): print('$', *self._cmd, *cmd, sep=' ') process = subprocess.Popen(self._cmd + list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = process.communicate() if process.returncode == 0: return json.loads(str(out, 'utf-8')) raise NodeException(str(err, 'utf-8')) def update_aliases(self): with self._update_lock: if time.time() - self._updated < _24H: return try: graph = self._command('describegraph') except NodeException as e: print(e) return aliases = {} for node in graph['nodes']: aliases[node['pub_key']] = node['alias'] self.aliases = aliases self._updated = time.time() def bg_update_aliases(self): th = threading.Thread(target=lambda: self.update_aliases()) th.start() def info(self): """Get information about the node""" obj = self._command('getinfo') n_chs = len(self._command('listchannels')['channels']) rows = [obj['alias']] self._1ml and rows.append(ND_LINK % obj['identity_pubkey']) self._lightblock and rows.append(ND_LINK_ALT % obj['identity_pubkey']) rows.append('Active channels: %s' % obj['num_active_channels']) rows.append('Channels: %d' % n_chs) if obj['num_pending_channels']: rows.append('Pending channels: %s' % obj['num_pending_channels']) rows.append('Num peers: %s' % obj['num_peers']) if obj['uris']: rows.append('%s' % obj['uris'][0]) if not obj['synced_to_chain']: rows.append('Not synced') rows.append(self.balance()) rows.append(self.feereport()) commit = git.get_git_revision_short_hash() if commit: rows.append('Version: %s' % commit) return '\n'.join(rows) def uri(self): """Get the node uri tg> uri""" return self._command('getinfo')['uris'][0] def pay(self, pay_req, amt=None): """Pay an invoice tg> pay <payment request> [amt] If amt is a float it is considered a bitcoin amount, if amt is an integer it is considered a satoshi amount""" # lncli payinvoice [command options] pay_req cmd = ['payinvoice', '-f'] if pay_req.lower().startswith('lightning:'): pay_req = pay_req[10:] if amt: cmd.append('--amt') cmd.append('%d' % amt_to_sat(amt, self.fiat)) cmd.append(pay_req) out = self._command(*cmd) rows = [] if out['payment_error']: rows.append('Error: %s' % out['payment_error']) else: route = out['payment_route'] rows.append('Amount: %s btc' % to_btc_str(route['total_amt'])) rows.append('Fee: %s sat' % to_sat_str( route['total_fees_msat'] if 'total_fees_msat' in route else 0)) nodes = [] for hop in route['hops']: if 'pub_key' in hop: nodes.append(self._alias(hop['pub_key'])) if nodes: rows.append('Path:') rows.extend(nodes) else: rows.append('# hops: %d' % len(route['hops'])) return '\n'.join(rows) def add(self, amt=None): """Add invoice tg> add [amt] If amt is a float it is considered a bitcoin amount, if amt is an integer it is considered a satoshi amount""" # lncli addinvoice value cmd = ['addinvoice', '--expiry', '43200'] if amt: try: cmd.append('%d' % amt_to_sat(amt, self.fiat)) except RateError as exception: return exception.args[0] if exception.args else 'Error' out = self._command(*cmd) if 'pay_req' in out: return out['pay_req'], out['r_hash'] else: return out @staticmethod def __is_expired(expiration: int): return time.time() > expiration def payment(self, r_hash=None): """Check a payment status tg> payment [r_hash] If r_hash is not provided the last payment will be checked """ PAID = '\U0001f44d' NOT_PAID = '\U0001f44e' NOT_FOUND = 'Invoice not found' if r_hash and len(r_hash) == 64 and re.match('^[\da-f]{64}$', r_hash): invoice = self._command('lookupinvoice', r_hash) else: invoices = self._command('listinvoices', '--max_invoices', '1')['invoices'] if not invoices: return NOT_FOUND invoice = invoices[0] rows = [] paid = PAID if invoice['settled'] else NOT_PAID rows.append('{} {}'.format(to_btc_str(invoice['value']), paid)) if not r_hash: r_hex = base64.decodebytes(bytes(invoice['r_hash'], 'ascii')).hex() rows.append(r_hex) creation = time.ctime(int(invoice['creation_date'])) rows.append('Created on {}'.format(creation)) if invoice['settled']: settled = time.ctime(int(invoice['settle_date'])) rows.append('Settled on {}'.format(settled)) else: expiration = time.ctime( int(invoice['creation_date']) + int(invoice['expiry'])) expired = self.__is_expired( int(invoice['creation_date']) + int(invoice['expiry'])) exp_format = 'Expired on {}' if expired else 'Expires {}' rows.append(exp_format.format(expiration)) return '\n'.join(rows) def balance(self): """Walletbalance and channelbalance tg> balance""" wallet = self._command('walletbalance') channel = self._command('channelbalance') rows = ['Wallet'] show_fiat = True for key in wallet: desc = key.replace('_', ' ') rows.append('%s: %s' % (desc, to_btc_str(wallet[key]))) if show_fiat and int(wallet[key]): try: rows[-1] += ' [%s]' % (self.fiat.to_fiat_str( int(wallet[key]), )) except RateError: show_fiat = False rows.append('Channel') for key in channel: desc = key.replace('_', ' ') rows.append('%s: %s' % (desc, to_btc_str(channel[key]))) if show_fiat and int(channel[key]): try: rows[-1] += ' [%s]' % (self.fiat.to_fiat_str( int(channel[key]), )) except RateError: show_fiat = False return '\n'.join(rows) def address(self): """Generate a new bech32 bitcoin address tg> address""" out = self._command('newaddress', 'p2wkh') return out['address'] def _alias(self, pubkey, default=None): """Return a not null alias or the pubkey""" self.bg_update_aliases() return self.aliases.get(pubkey) or default or self._city_alias(pubkey) def _city_alias(self, pubkey): CITYSCAPE = '\U0001f3d9' CITY_DUSK = '\U0001f306' if self._cities is None: self._cities = read_cities() city = self._cities[self._int_hash_pubkey(pubkey) % len(self._cities)] emoji = CITY_DUSK if self.aliases else CITYSCAPE return emoji + ' ' + city @staticmethod def _int_hash_pubkey(pubkey): hash = hashlib.sha256(binascii.unhexlify(pubkey)).digest() return int.from_bytes(hash, byteorder='big', signed=False) def channels(self, filter_by_alias=None, pending=True): """List channels tg> channles [filter] Specify a filter to select channels by aliases and pubkeys""" # lncli listchannels chs = self._command('listchannels')['channels'] messages = [] for ch in chs: rows = [] pubkey = ch['remote_pubkey'] alias = self._alias(pubkey) if not filter_by_alias or filter_by_alias in alias or filter_by_alias in pubkey: active = '\u26a1\ufe0f' if ch['active'] else '\U0001f64a' private = '\U0001f512' if ch['private'] else '' rows.append('%s %s%s' % (alias, active, private)) if not private and ch['chan_id'] != '0': self._1ml and rows.append(CH_LINK % ch['chan_id']) self._lightblock and rows.append( CH_LINK_ALT % ch['chan_id']) rows.append(to_btc_str(ch['capacity'])) local = to_btc_str(ch['local_balance']) remote = to_btc_str(ch['remote_balance']) rows.append('L: %s R: %s' % (local, remote)) rows.append(TX_LINK % (ch['channel_point'].split(':')[0])) messages.append('\n'.join(rows)) if pending: messages += self.pending(filter_by_alias) return messages def chs(self): """Short version of channels tg> chs""" chs = self._command('listchannels')['channels'] rows = [] show_fiat = True for ch in sorted(chs, key=lambda x: x['private']): pubkey = ch['remote_pubkey'] active = '\u26a1\ufe0f' if ch['active'] else '\U0001f64a' capacity = to_btc_str(ch['capacity']).rstrip('0').rstrip('.') private = '\U0001f512' if ch['private'] else '' capacity_eur = '' if show_fiat: try: capacity_eur = ' [%s]' % (self.fiat.to_fiat_str( int(ch['capacity']), )) except RateError: show_fiat = False rows.append( '%s%s %s %s%s' % (active, private, self._alias(pubkey), capacity, capacity_eur)) return '\n'.join(rows) def pending(self, filter_by_alias=None): """List pending channels tg> pending [filter] Specify a filter to select pending channels by aliases and pubkeys""" chs = self._command('pendingchannels')['pending_open_channels'] messages = [] for ch in chs: rows = [] pubkey = ch['channel']['remote_node_pub'] alias = self._alias(pubkey) if not filter_by_alias or filter_by_alias in alias or filter_by_alias in pubkey: rows.append('%s \u23f3' % (alias, )) rows.append(to_btc_str(ch['channel']['capacity'])) local = to_btc_str(ch['channel']['local_balance']) remote = to_btc_str(ch['channel']['remote_balance']) rows.append('L: %s R: %s' % (local, remote)) rows.append(TX_LINK % (ch['channel']['channel_point'].split(':')[0])) messages.append('\n'.join(rows)) return messages def feereport(self): out = self._command('feereport') return 'Fees\nday: %s, week: %s, month: %s' % ( out['day_fee_sum'], out['week_fee_sum'], out['month_fee_sum']) def is_pay_req(self, pay_req): if pay_req.lower().startswith('lightning:'): pay_req = pay_req[10:] try: self._command('decodepayreq', pay_req) except NodeException: return False else: return True def n_1ml(self): """Toggle https://1ml.com block explorer links tg> 1ml""" self._1ml = not self._1ml return '1ml toggled' def lightblock(self): """Toggle https://lightblock.me block explorer links tg> lightblock""" self._lightblock = not self._lightblock return 'lightblock toggled' def decode(self, pay_req): """Decode a payment request tg> decode <payment request>""" if not self.is_pay_req(pay_req): return 'This is not a payment request' decoded = self._command('decodepayreq', pay_req) pubkey = decoded['destination'] rows = [] alias = self._alias(pubkey, '-') if alias != '-': rows.append('To {}'.format(alias)) rows.append('Pubkey {}'.format(pubkey)) amount = int(decoded['num_satoshis']) if amount: if amount > .0001 * 1e8: amount_str = 'Amount {} btc'.format(to_btc_str(amount)) else: amount_str = 'Amount {} sat'.format(amount) rows.append(amount_str) if decoded['description']: rows.append('Description {}'.format(decoded['description'])) creation = time.ctime(int(decoded['timestamp'])) rows.append('Created on {}'.format(creation)) expiration = time.ctime( int(decoded['timestamp']) + int(decoded['expiry'])) expired = self.__is_expired( int(decoded['timestamp']) + int(decoded['expiry'])) exp_format = 'Expired on {}' if expired else 'Expires {}' rows.append(exp_format.format(expiration)) return '\n'.join(rows)