mirror of
https://github.com/fusion44/blitz_api.git
synced 2026-08-14 12:02:46 +02:00
923 lines
31 KiB
Python
923 lines
31 KiB
Python
import asyncio
|
|
import json
|
|
import sys
|
|
import time
|
|
from typing import AsyncGenerator, Dict, List, Optional, Union
|
|
|
|
import decouple
|
|
import grpc
|
|
from decouple import config
|
|
from fastapi.exceptions import HTTPException
|
|
from loguru import logger
|
|
from starlette import status
|
|
|
|
import app.lightning.impl.protos.cln.node_pb2 as ln
|
|
import app.lightning.impl.protos.cln.node_pb2_grpc as clnrpc
|
|
import app.lightning.impl.protos.cln.primitives_pb2 as lnp
|
|
from app.api.utils import SSE, broadcast_sse_msg, config_get_hex_str, next_push_id
|
|
from app.bitcoind.utils import bitcoin_rpc_async
|
|
from app.lightning.impl.cln_utils import (
|
|
calc_fee_rate_str,
|
|
cln_classify_fee_revenue,
|
|
parse_cln_msat,
|
|
)
|
|
from app.lightning.impl.ln_base import LightningNodeBase
|
|
from app.lightning.models import (
|
|
Channel,
|
|
FeeRevenue,
|
|
ForwardSuccessEvent,
|
|
GenericTx,
|
|
InitLnRepoUpdate,
|
|
Invoice,
|
|
InvoiceState,
|
|
LnInfo,
|
|
LnInitState,
|
|
NewAddressInput,
|
|
OnchainAddressType,
|
|
OnChainTransaction,
|
|
Payment,
|
|
PaymentRequest,
|
|
SendCoinsInput,
|
|
SendCoinsResponse,
|
|
TxStatus,
|
|
WalletBalance,
|
|
)
|
|
from app.lightning.utils import generic_grpc_error_handler
|
|
|
|
_WAIT_ANY_INVOICE_ID = 0
|
|
_SOCKET_BUFFER_SIZE_LIMIT = 1024 * 1024 * 10 # 10 MB
|
|
|
|
|
|
class LnNodeCLNjRPC(LightningNodeBase):
|
|
lastpay_index = 0
|
|
_current_id: int = _WAIT_ANY_INVOICE_ID + 1
|
|
_futures: dict[int, asyncio.Future] = {}
|
|
_socket_path: str = None
|
|
_reader: asyncio.StreamReader = None
|
|
_writer: asyncio.StreamWriter = None
|
|
_loop: asyncio.AbstractEventLoop = None
|
|
_initialized: bool = False
|
|
_invoice_queue = asyncio.Queue()
|
|
|
|
# Decoding the payment request take a long time,
|
|
# hence we build a simple cache here.
|
|
_bolt11_cache: dict[str, PaymentRequest] = {}
|
|
_block_cache = {}
|
|
|
|
def get_implementation_name(self) -> str:
|
|
return "CLN_JRPC"
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def initialize(self) -> AsyncGenerator[InitLnRepoUpdate, None]:
|
|
logger.info("Initializing CLN JSON-RPC implementation.")
|
|
if self._initialized:
|
|
logger.warning(
|
|
"Connection already initialized. This function must not be called twice."
|
|
)
|
|
yield InitLnRepoUpdate(state=LnInitState.DONE)
|
|
|
|
yield InitLnRepoUpdate(state=LnInitState.BOOTSTRAPPING)
|
|
|
|
try:
|
|
self._socket_path = decouple.config("cln_jrpc_path")
|
|
except decouple.UndefinedValueError as e:
|
|
logger.debug(e)
|
|
logger.error(
|
|
f"CLN JSON-RPC implementation set, but cln_jrpc_path is missing from the config file."
|
|
)
|
|
sys.exit(1)
|
|
|
|
logger.info(
|
|
f"Establishing a connection to the CLN socket at {self._socket_path}"
|
|
)
|
|
|
|
self._loop = asyncio.get_running_loop()
|
|
self._reader, self._writer = await asyncio.open_unix_connection(
|
|
path=self._socket_path,
|
|
limit=_SOCKET_BUFFER_SIZE_LIMIT,
|
|
)
|
|
|
|
asyncio.create_task(self._read_loop())
|
|
|
|
logger.info("Setting up invoice waitanyinvoice subscription.")
|
|
await self._refresh_invoice_sub(True)
|
|
|
|
yield InitLnRepoUpdate(state=LnInitState.DONE)
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def get_wallet_balance(self) -> WalletBalance:
|
|
logger.trace("get_wallet_balance()")
|
|
|
|
# TODO: CLN doesn't currently include pending mempool transactions
|
|
# implement the following:
|
|
# https://github.com/andrewtoth/listmempoolfunds/blob/master/listmempoolfunds.py
|
|
|
|
res = await self._send_request("listfunds")
|
|
res = res["result"]
|
|
onchain_confirmed = onchain_unconfirmed = onchain_total = 0
|
|
chan_local = chan_remote = chan_pending_local = chan_pending_remote = 0
|
|
|
|
for o in res["outputs"]:
|
|
sat = parse_cln_msat(o["amount_msat"]) / 1000
|
|
onchain_total += sat
|
|
|
|
if o["status"] == "unconfirmed":
|
|
onchain_unconfirmed += sat
|
|
elif o["status"] == "confirmed":
|
|
onchain_confirmed += sat
|
|
|
|
for c in res["channels"]:
|
|
our_msat = parse_cln_msat(c["our_amount_msat"])
|
|
their_msat = parse_cln_msat(c["amount_msat"]) - our_msat
|
|
|
|
if c["state"] == "CHANNELD_NORMAL":
|
|
chan_local += our_msat
|
|
chan_remote += their_msat
|
|
else:
|
|
# treat everything else as pending for now
|
|
chan_pending_local += our_msat
|
|
chan_pending_remote += their_msat
|
|
|
|
return WalletBalance(
|
|
onchain_confirmed_balance=onchain_confirmed,
|
|
onchain_total_balance=onchain_total,
|
|
onchain_unconfirmed_balance=onchain_unconfirmed,
|
|
channel_local_balance=chan_local,
|
|
channel_remote_balance=chan_remote,
|
|
# TODO: find out how to get these values with CLN
|
|
channel_unsettled_local_balance=0,
|
|
channel_unsettled_remote_balance=0,
|
|
channel_pending_open_local_balance=chan_pending_local,
|
|
channel_pending_open_remote_balance=chan_pending_remote,
|
|
)
|
|
|
|
async def _get_block_time(self, block_height: int) -> tuple:
|
|
logger.trace(f"_get_block_time(block_height={block_height}) ")
|
|
|
|
if block_height is None or block_height < 0:
|
|
raise ValueError("block_height cannot be None or negative")
|
|
|
|
if block_height in self._block_cache:
|
|
return self._block_cache[block_height]
|
|
|
|
res = await bitcoin_rpc_async("getblockstats", params=[block_height])
|
|
hash = res["result"]["blockhash"]
|
|
block = await bitcoin_rpc_async("getblock", params=[hash])
|
|
self._block_cache[block_height] = (
|
|
block["result"]["time"],
|
|
block["result"]["mediantime"],
|
|
)
|
|
|
|
return self._block_cache[block_height]
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def list_all_tx(
|
|
self, successful_only: bool, index_offset: int, max_tx: int, reversed: bool
|
|
) -> List[GenericTx]:
|
|
logger.trace(
|
|
f"list_all_tx({successful_only}, {index_offset}, {max_tx}, {reversed})"
|
|
)
|
|
|
|
res = await asyncio.gather(
|
|
*[
|
|
self.list_invoices(False, 0, 0, False),
|
|
self.list_on_chain_tx(),
|
|
self.list_payments(True, 0, 0, False),
|
|
self.get_ln_info(),
|
|
]
|
|
)
|
|
|
|
if res[0] is None:
|
|
logger.error("list_invoices() returned None")
|
|
if res[1] is None:
|
|
logger.error("list_on_chain_tx() returned None")
|
|
if res[2] is None:
|
|
logger.error("list_payments() returned None")
|
|
if res[3] is None:
|
|
logger.error("get_ln_info() returned None")
|
|
|
|
tx = []
|
|
for invoice in res[0]:
|
|
i = GenericTx.from_invoice(invoice)
|
|
if successful_only and i["status"] == "succeeded":
|
|
tx.append(i)
|
|
continue
|
|
tx.append(i)
|
|
|
|
for transaction in res[1]:
|
|
t = GenericTx.from_onchain_tx(transaction, res[3].block_height)
|
|
if successful_only and t.status == TxStatus.SUCCEEDED:
|
|
tx.append(t)
|
|
continue
|
|
|
|
tx.append(t)
|
|
|
|
for pay in res[2]: # type: Payment
|
|
comment = ""
|
|
|
|
if pay.payment_request is not None and len(pay.payment_request) > 0:
|
|
b11 = await self._decode_bolt11_cached(pay.payment_request)
|
|
comment = b11.description
|
|
|
|
p = GenericTx.from_payment(pay, comment)
|
|
|
|
if successful_only and p["status"] == "succeeded":
|
|
tx.append(p)
|
|
continue
|
|
|
|
tx.append(p)
|
|
|
|
def sortKey(e: GenericTx):
|
|
return e.time_stamp
|
|
|
|
tx.sort(key=sortKey)
|
|
|
|
if reversed:
|
|
tx.reverse()
|
|
|
|
l = len(tx)
|
|
for invoice in range(l):
|
|
tx[invoice].index = invoice
|
|
|
|
if max_tx == 0:
|
|
max_tx = l
|
|
|
|
return tx[index_offset : index_offset + max_tx]
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def list_invoices(
|
|
self,
|
|
pending_only: bool,
|
|
index_offset: int,
|
|
num_max_invoices: int,
|
|
reversed: bool,
|
|
):
|
|
logger.trace(
|
|
f"list_invoices({pending_only}, {index_offset}, {num_max_invoices}, {reversed})"
|
|
)
|
|
|
|
res = await self._send_request("listinvoices")
|
|
|
|
if "error" in res:
|
|
self._raise_internal_server_error("listing invoices", res)
|
|
|
|
if not "result" in res or not "invoices" in res["result"]:
|
|
logger.error(f"Got no error and no invoices key result: {res}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Error while listing invoices: {res}",
|
|
)
|
|
|
|
res = res["result"]["invoices"]
|
|
|
|
invoices = []
|
|
for i in res:
|
|
if not pending_only:
|
|
invoices.append(Invoice.from_cln_json(i))
|
|
continue
|
|
|
|
if i["status"] == "unpaid":
|
|
invoices.append(Invoice.from_cln_json(i))
|
|
|
|
if reversed:
|
|
invoices.reverse()
|
|
|
|
if num_max_invoices == 0 or num_max_invoices is None:
|
|
return invoices
|
|
|
|
return invoices[index_offset : index_offset + num_max_invoices]
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def list_on_chain_tx(self) -> List[OnChainTransaction]:
|
|
logger.trace("list_on_chain_tx()")
|
|
info = await self.get_ln_info() # for current block height
|
|
res = await self._send_request("bkpr-listincome")
|
|
if "error" in res:
|
|
self._raise_internal_server_error("listing on-chain transactions", res)
|
|
res = res["result"]
|
|
|
|
txs = {}
|
|
num_events = len(res["income_events"])
|
|
for i in range(0, num_events):
|
|
e = res["income_events"][i]
|
|
if e["account"] != "wallet":
|
|
continue
|
|
|
|
if e["tag"] == "deposit" or e["tag"] == "withdrawal":
|
|
tx = OnChainTransaction.from_cln_bkpr(e)
|
|
txs[tx.tx_hash] = tx
|
|
elif e["tag"] == "onchain_fee":
|
|
if e["txid"] in txs:
|
|
txs[e["txid"]].total_fees = parse_cln_msat(e["debit_msat"]) / 1000
|
|
|
|
# TODO: Improve this once CLN reports the block height in bkpr-listincome
|
|
# see https://github.com/ElementsProject/lightning/issues/5694
|
|
|
|
# now get the block height for each tx ...
|
|
res = await self._send_request("bkpr-listaccountevents")
|
|
if "error" in res:
|
|
self._raise_internal_server_error("listing on-chain transactions", res)
|
|
res = res["result"]
|
|
|
|
num_events = len(res["events"])
|
|
for i in range(0, num_events):
|
|
e = res["events"][i]
|
|
if e["account"] != "wallet" or e["type"] != "chain":
|
|
continue
|
|
|
|
txid = ""
|
|
if e["tag"] == "deposit":
|
|
txid = e["outpoint"].split(":")[0]
|
|
elif e["tag"] == "withdrawal":
|
|
txid = e["txid"]
|
|
|
|
if len(txid) == 0:
|
|
continue
|
|
|
|
if txid in txs:
|
|
txs[txid].block_height = e["blockheight"]
|
|
txs[txid].num_confirmations = info.block_height - txs[txid].block_height
|
|
|
|
return [txs[k] for k in txs.keys()]
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def list_payments(
|
|
self,
|
|
include_incomplete: bool,
|
|
index_offset: int,
|
|
max_payments: int,
|
|
reversed: bool,
|
|
):
|
|
logger.trace(
|
|
f"list_payments({include_incomplete}, {index_offset}, {max_payments}, {reversed})"
|
|
)
|
|
|
|
res = await self._send_request("listpays")
|
|
if "error" in res:
|
|
self._raise_internal_server_error("listing payments", res)
|
|
|
|
if not "result" in res or not "pays" in res["result"]:
|
|
logger.error(f"Got no error and no pays key result: {res}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Error while listing payments: {res}",
|
|
)
|
|
|
|
res = res["result"]["pays"]
|
|
|
|
pays = []
|
|
for p in res:
|
|
if p["status"] == "complete":
|
|
pays.append(Payment.from_cln_jrpc(p))
|
|
continue
|
|
|
|
if include_incomplete:
|
|
b11_decoded = await self._decode_bolt11_cached(p["bolt11"])
|
|
p["amount_msat"] = b11_decoded.num_msat
|
|
pays.append(Payment.from_cln_jrpc(p))
|
|
|
|
if reversed:
|
|
pays.reverse()
|
|
|
|
if max_payments == 0 or max_payments is None:
|
|
return pays
|
|
|
|
return pays[index_offset : index_offset + max_payments]
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def add_invoice(
|
|
self,
|
|
value_msat: int,
|
|
memo: str = "",
|
|
expiry: int = 3600,
|
|
is_keysend: bool = False,
|
|
) -> Invoice:
|
|
if value_msat < 0:
|
|
raise ValueError("value_msat cannot be negative")
|
|
|
|
pid = next_push_id()
|
|
params = [value_msat, pid, memo, expiry]
|
|
res = await self._send_request("invoice", params)
|
|
|
|
if not "error" in res:
|
|
res = res["result"]
|
|
return Invoice(
|
|
payment_request=res["bolt11"],
|
|
memo=memo,
|
|
value_msat=value_msat,
|
|
expiry_date=res["expires_at"],
|
|
add_index=pid,
|
|
state=InvoiceState.OPEN,
|
|
)
|
|
|
|
m = res["error"]["message"]
|
|
if "Duplicate label" in m:
|
|
logger.error(m)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=m
|
|
)
|
|
|
|
logger.error(m)
|
|
raise HTTPException(
|
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Unknown error: {m}",
|
|
)
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def decode_pay_request(self, pay_req: str) -> PaymentRequest:
|
|
if pay_req in self._bolt11_cache:
|
|
return self._bolt11_cache[pay_req]
|
|
|
|
params = [pay_req]
|
|
res = await self._send_request("decodepay", params)
|
|
|
|
if not "error" in res:
|
|
res = res["result"]
|
|
req = PaymentRequest.from_cln_json(res)
|
|
self._bolt11_cache[pay_req] = req
|
|
return req
|
|
|
|
m = res["error"]["message"]
|
|
logger.error(m)
|
|
|
|
raise HTTPException(
|
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Unknown error: {m}",
|
|
)
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def get_fee_revenue(self) -> FeeRevenue:
|
|
params = ["settled"] # only list settled forwards
|
|
res = await self._send_request("listforwards", params)
|
|
|
|
if not "error" in res:
|
|
res = res["result"]
|
|
day, week, month, year, total = cln_classify_fee_revenue(res["forwards"])
|
|
|
|
return FeeRevenue(day=day, week=week, month=month, year=year, total=total)
|
|
|
|
m = res["error"]["message"]
|
|
logger.error(m)
|
|
|
|
raise HTTPException(
|
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Unknown error: {m}",
|
|
)
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def new_address(self, input: NewAddressInput) -> str:
|
|
res = await self._send_request("newaddr")
|
|
|
|
if not "error" in res:
|
|
res = res["result"]
|
|
return res["bech32"]
|
|
|
|
m = res["error"]["message"]
|
|
logger.error(m)
|
|
|
|
raise HTTPException(
|
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Unknown error: {m}",
|
|
)
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def send_coins(self, input: SendCoinsInput) -> SendCoinsResponse:
|
|
fee_rate: calc_fee_rate_str(input.sat_per_vbyte, input.target_conf)
|
|
|
|
amt = "all" if input.send_all else input.amount
|
|
|
|
params = [input.address, input.amount, fee_rate]
|
|
res = await self._send_request("withdraw", params)
|
|
|
|
if not "error" in res:
|
|
res = res["result"]
|
|
r = SendCoinsResponse.from_cln_json(res, input)
|
|
await broadcast_sse_msg(SSE.LN_ONCHAIN_PAYMENT_STATUS, r.dict())
|
|
|
|
return r
|
|
|
|
m = res["error"]["message"]
|
|
logger.error(m)
|
|
|
|
raise HTTPException(
|
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Unknown error: {m}",
|
|
)
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def send_payment(
|
|
self,
|
|
pay_req: str,
|
|
timeout_seconds: int,
|
|
fee_limit_msat: int,
|
|
amount_msat: Optional[int] = None,
|
|
) -> Payment:
|
|
logger.trace("send_payment({pay_req}, {amount_msat}, {fee_limit_msat})")
|
|
|
|
# pay bolt11 [amount_msat] [label] [riskfactor] [maxfeepercent]
|
|
# [retry_for] [maxdelay] [exemptfee] [localinvreqid] [exclude]
|
|
# [maxfee] [description]
|
|
|
|
res = await self._send_request("listpays", {"bolt11": pay_req})
|
|
if "error" in res:
|
|
e = res["error"]["message"]
|
|
if "Invalid invstring: unexpected prefix" in e:
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
detail="invalid bech32 string",
|
|
)
|
|
|
|
self._raise_internal_server_error("checking if invoice was paid", res)
|
|
|
|
pays = res["result"]["pays"]
|
|
if len(pays) > 0 and pays[0]["status"] == "complete":
|
|
raise HTTPException(
|
|
status.HTTP_409_CONFLICT, detail="invoice is already paid"
|
|
)
|
|
|
|
params = {
|
|
"bolt11": pay_req,
|
|
"maxfee": fee_limit_msat,
|
|
"retry_for": timeout_seconds,
|
|
**({"msatoshi": amount_msat} if amount_msat is not None else {}),
|
|
}
|
|
res = await self._send_request("pay", params)
|
|
|
|
if not "error" in res:
|
|
res = res["result"]
|
|
return Payment.from_cln_jrpc(res)
|
|
|
|
message = res["error"]["message"]
|
|
if "Ran out of routes to try after" in message:
|
|
attempts = message.split("Ran out of routes to try after ")[1]
|
|
attempts = attempts.split(" attempts")[0]
|
|
raise HTTPException(
|
|
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Ran out of routes to try after {attempts} attempts.",
|
|
)
|
|
|
|
if "Invalid bolt11: " in message:
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
detail="invalid bech32 string",
|
|
)
|
|
|
|
if "amount_msat parameter required" in message:
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
detail="amount must be specified when paying a zero amount invoice",
|
|
)
|
|
|
|
if (
|
|
"amount_msat parameter unnecessary" in message
|
|
or "msatoshi parameter unnecessary" in message
|
|
):
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
detail="amount must not be specified when paying a non-zero amount invoice",
|
|
)
|
|
|
|
logger.error(message)
|
|
raise HTTPException(
|
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Unknown error: {message}",
|
|
)
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def get_ln_info(self) -> LnInfo:
|
|
res = await self._send_request("getinfo")
|
|
if "error" in res:
|
|
self._raise_internal_server_error("getting info", res)
|
|
|
|
return LnInfo.from_cln_jrpc(self.get_implementation_name(), res["result"])
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def unlock_wallet(self, password: str) -> bool:
|
|
logger.trace(f"unlock_wallet(password=wedontlogpasswords)")
|
|
|
|
# Core Lightning doesn't lock wallets,
|
|
# so we don't need to do anything here
|
|
return True
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def listen_invoices(self) -> AsyncGenerator[Invoice, None]:
|
|
logger.trace("listen_invoices()")
|
|
|
|
while True:
|
|
try:
|
|
data = await self._invoice_queue.get()
|
|
data = json.loads(data)["result"]
|
|
i = Invoice.from_cln_json(data)
|
|
self.lastpay_index = i.settle_index
|
|
await self._refresh_invoice_sub()
|
|
yield i
|
|
except Exception as e:
|
|
logger.error(f"Got an invoice but could not parse the data: {e}")
|
|
# if we have an error we are in an unknown state
|
|
# so we fetch the latest invoice index and start from there
|
|
await self._refresh_invoice_sub(True)
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def listen_forward_events(self) -> ForwardSuccessEvent:
|
|
logger.trace("listen_forward_events()")
|
|
|
|
# CLN has no subscription to forwarded events.
|
|
# We must poll instead.
|
|
|
|
interval = config("gather_ln_info_interval", default=2, cast=float)
|
|
|
|
# make sure we know how many forwards we have
|
|
# we need to calculate the difference between each iteration
|
|
res = await self._send_request("listforwards", {"status": "settled"})
|
|
|
|
if "error" in res:
|
|
self._raise_internal_server_error("getting forwards", res)
|
|
|
|
res = res["result"]
|
|
num_fwd_last_poll = len(res["forwards"])
|
|
while True:
|
|
res = await self._send_request("listforwards", {"status": "settled"})
|
|
res = res["result"]
|
|
if len(res["forwards"]) > num_fwd_last_poll:
|
|
fwds = res["forwards"][num_fwd_last_poll:]
|
|
|
|
for fwd in fwds:
|
|
yield ForwardSuccessEvent.from_cln_json(fwd)
|
|
|
|
num_fwd_last_poll = len(res["forwards"])
|
|
await asyncio.sleep(interval - 0.1)
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def channel_open(
|
|
self, local_funding_amount: int, node_URI: str, target_confs: int
|
|
) -> str:
|
|
logger.trace(
|
|
f"channel_open(local_funding_amount={local_funding_amount}, node_URI={node_URI}, target_confs={target_confs})"
|
|
)
|
|
# fundchannel id amount [feerate] [announce] [minconf] [utxos] [push_msat] [close_to] [request_amt] [compact_lease] [reserve]
|
|
|
|
await self.connect_peer(node_URI)
|
|
fee_rate = calc_fee_rate_str(None, target_confs)
|
|
pub = node_URI.split("@")[0]
|
|
params = {"id": pub, "amount": local_funding_amount, "feerate": fee_rate}
|
|
res = await self._send_request("fundchannel", params)
|
|
|
|
if "error" in res:
|
|
self._handle_open_channel_error(res["error"])
|
|
res = res["result"]
|
|
|
|
if "txid" in res and "channel_id" in res:
|
|
return res["txid"]
|
|
|
|
def _handle_open_channel_error(self, error):
|
|
logger.trace(f"_handle_open_channel_error({error})")
|
|
message = error["message"]
|
|
|
|
if "amount: should be a satoshi amount" in message:
|
|
logger.error(message)
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
detail="The amount is not a valid satoshi amount.",
|
|
)
|
|
|
|
if "Unknown peer" in message:
|
|
raise HTTPException(
|
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="We where able to connect to the peer but CLN can't find it when opening a channel.",
|
|
)
|
|
|
|
if "Owning subdaemon openingd died" in message:
|
|
# https://github.com/ElementsProject/lightning/issues/2798#issuecomment-511205719
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
detail="Likely the peer didn't like our channel opening proposal and disconnected from us.",
|
|
)
|
|
|
|
if (
|
|
"Number of pending channels exceed maximum" in message
|
|
or "exceeds maximum chan size of 10 BTC" in message
|
|
or "Could not afford" in message
|
|
):
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=message)
|
|
|
|
logger.error(message)
|
|
raise HTTPException(
|
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Unknown error: {message}",
|
|
)
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def peer_resolve_alias(self, node_pub: str) -> str:
|
|
logger.trace(f"peer_resolve_alias(node_pub={node_pub})")
|
|
|
|
if node_pub == "":
|
|
raise ValueError("node_pub is empty")
|
|
|
|
res = await self._send_request("listnodes", [node_pub])
|
|
|
|
if "error" in res:
|
|
err_msg = res["error"]["message"]
|
|
logger.error(f"Error resolving alias for node_pub={node_pub}\n{err_msg}")
|
|
return ""
|
|
|
|
nodes = res["result"]["nodes"]
|
|
if len(nodes) == 0:
|
|
return ""
|
|
|
|
return str(nodes[0]["alias"])
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def channel_list(self) -> List[Channel]:
|
|
logger.trace("channel_list()")
|
|
|
|
res = await self._send_request("listfunds")
|
|
if "error" in res:
|
|
self._raise_internal_server_error("listing channels", res)
|
|
|
|
res = res["result"]
|
|
|
|
peer_ids = [c["peer_id"] for c in res["channels"]]
|
|
peers = await asyncio.gather(*[self.peer_resolve_alias(p) for p in peer_ids])
|
|
channels = [Channel.from_cln_jrpc(c, p) for c, p in zip(res["channels"], peers)]
|
|
|
|
return channels
|
|
|
|
@logger.catch(exclude=(HTTPException,))
|
|
async def channel_close(self, channel_id: int, force_close: bool) -> str:
|
|
# https://lightning.readthedocs.io/lightning-close.7.html
|
|
logger.trace(
|
|
f"channel_close(channel_id={channel_id}, force_close={force_close})"
|
|
)
|
|
|
|
# on CLN we wait for 2 minutes to negotiate a channel close
|
|
# if peer doesn't respond we force close
|
|
params = {
|
|
"id": channel_id,
|
|
"unilateraltimeout": 120 if force_close else 0,
|
|
}
|
|
res = await self._send_request("close", params)
|
|
if "error" in res:
|
|
message = res["error"]["message"]
|
|
if (
|
|
"Short channel ID not active:" in message
|
|
or "Short channel ID not found" in message
|
|
):
|
|
logger.warning(f"Error while closing channel {channel_id}: {message}")
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=message)
|
|
|
|
self._raise_internal_server_error("closing channel", res)
|
|
|
|
res = res["result"]
|
|
|
|
# “mutual”, “unilateral”, “unopened”
|
|
t = res["type"]
|
|
if t == "mutual" or t == "unilateral":
|
|
return res["txid"]
|
|
elif t == "unopened":
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST, detail="Channel is not open yet."
|
|
)
|
|
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
detail=f"CLN returned unknown close type: {t}",
|
|
)
|
|
|
|
async def connect_peer(self, uri: str) -> bool:
|
|
logger.trace(f"connect_peer(node_URI={uri})")
|
|
|
|
res = await self._send_request("connect", [uri])
|
|
|
|
if not "error" in res:
|
|
return True
|
|
|
|
message = res["error"]["message"]
|
|
|
|
if "All addresses failed" in message:
|
|
message = details.split('message: "')[1]
|
|
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
detail=message,
|
|
)
|
|
|
|
if "no address known for peer" in message:
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
detail="Connection establishment: No address known for peer",
|
|
)
|
|
|
|
if "Connection timed out" in message:
|
|
raise HTTPException(
|
|
status.HTTP_504_GATEWAY_TIMEOUT,
|
|
detail="Connection establishment: Connection timed out.",
|
|
)
|
|
|
|
if "Connection refused" in message:
|
|
raise HTTPException(
|
|
status.HTTP_504_GATEWAY_TIMEOUT,
|
|
detail="Connection establishment: Connection refused.",
|
|
)
|
|
|
|
logger.exception(message)
|
|
|
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=message)
|
|
|
|
async def _read_loop(self):
|
|
logger.trace("_read_loop()")
|
|
|
|
while not self._writer.is_closing():
|
|
try:
|
|
data = await self._reader.readline()
|
|
data = data.decode("utf-8")
|
|
|
|
if data == "\n":
|
|
continue
|
|
|
|
if data:
|
|
self._handle_response(data)
|
|
except (ValueError, asyncio.exceptions.LimitOverrunError) as e:
|
|
logger.exception(e)
|
|
continue
|
|
|
|
def _handle_response(self, data):
|
|
logger.trace(f"_handle_response(data={data})")
|
|
|
|
response = json.loads(data)
|
|
id = response["id"]
|
|
|
|
if id == _WAIT_ANY_INVOICE_ID:
|
|
return self._invoice_queue.put_nowait(data)
|
|
|
|
logger.trace(f"Got response: {response} for id {self._current_id}")
|
|
future = self._futures[id]
|
|
future.set_result(response)
|
|
|
|
del self._futures[id]
|
|
|
|
def _send_request(self, method: str, params: Union[Dict, List, None] = {}):
|
|
self._current_id += 1
|
|
data = self._build_request_data(method, self._current_id, params)
|
|
logger.trace(f"Sending request: {data} with id {self._current_id}")
|
|
self._writer.write(data.encode("utf-8"))
|
|
future = self._loop.create_future()
|
|
self._futures[self._current_id] = future
|
|
|
|
return future
|
|
|
|
def _build_request_data(self, method: str, id: int, params) -> bytes:
|
|
return json.dumps(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
)
|
|
|
|
def _raise_internal_server_error(self, action, res):
|
|
err = res["error"]
|
|
logger.error(f"Error while {action}: {err}")
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Error while {action}: {err}",
|
|
)
|
|
|
|
async def _refresh_invoice_sub(self, refresh_pay_index=False):
|
|
if refresh_pay_index:
|
|
logger.info("Refreshing pay_index")
|
|
|
|
invoices = await self.list_invoices(
|
|
pending_only=False,
|
|
index_offset=0,
|
|
num_max_invoices=9999999999999,
|
|
reversed=True,
|
|
)
|
|
|
|
if invoices is not None:
|
|
for i in invoices: # type Invoice
|
|
if i.state is not InvoiceState.SETTLED:
|
|
continue
|
|
|
|
if i.settle_index != None and i.settle_index < self.lastpay_index:
|
|
break
|
|
|
|
self.lastpay_index = i.settle_index
|
|
|
|
data = self._build_request_data(
|
|
"waitanyinvoice",
|
|
_WAIT_ANY_INVOICE_ID,
|
|
{"lastpay_index": self.lastpay_index},
|
|
)
|
|
|
|
logger.trace(f"Sending waitanyinvoice request: {data}")
|
|
self._writer.write(data.encode("utf-8"))
|
|
|
|
async def _decode_bolt11_cached(self, bolt11: str) -> PaymentRequest:
|
|
if bolt11 in self._bolt11_cache:
|
|
return self._bolt11_cache[bolt11]
|
|
|
|
pr = await self.decode_pay_request(bolt11)
|
|
self._bolt11_cache[bolt11] = pr
|
|
return pr
|