Create lightning client base class (#1542)

* Add unit test for add invoice in test lnd lightning client

* Add new create invoice method of lightning client and add unit test
This commit is contained in:
Jonathan Zernik 2021-10-08 20:40:58 -07:00 committed by GitHub
parent fc691ffeb2
commit 6aae503de8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 389 additions and 76 deletions

View file

@ -7,7 +7,6 @@ RUN pip3 install -r requirements-itest.txt
WORKDIR /app
COPY proto ./proto
COPY itests/test.sh squeaknode/lightning/lnd_lightning_client.py ./
COPY itests/tests ./tests
RUN python3 -m grpc_tools.protoc --proto_path=. --python_out=. --grpc_python_out=. \

View file

@ -35,7 +35,6 @@ from tests.util import create_signing_profile
from tests.util import delete_profile
from tests.util import generate_signing_key
from tests.util import get_address
from tests.util import load_lightning_client
from tests.util import open_peer_connection
@ -57,11 +56,6 @@ def other_admin_stub():
yield squeak_admin_pb2_grpc.SqueakAdminStub(admin_channel)
@pytest.fixture
def lightning_client():
return load_lightning_client()
@pytest.fixture
def signing_key():
# Create a signing key

View file

@ -30,7 +30,7 @@ from squeak.core import CSqueak
from proto import lnd_pb2 as ln
from proto import squeak_admin_pb2
from tests.util import connect_peer
from tests.util import channel
from tests.util import create_contact_profile
from tests.util import create_saved_peer
from tests.util import create_signing_profile
@ -50,8 +50,9 @@ from tests.util import get_squeak_display
from tests.util import get_squeak_profile
from tests.util import import_signing_profile
from tests.util import make_squeak
from tests.util import open_channel
from tests.util import open_peer_connection
from tests.util import peer_connection
from tests.util import send_coins
from tests.util import subscribe_connected_peers
from tests.util import subscribe_squeak_ancestor_entries
from tests.util import subscribe_squeak_entry
@ -581,10 +582,10 @@ def test_delete_peer(admin_stub, peer_id):
assert not get_peer_response.HasField("squeak_peer")
def test_send_coins(admin_stub, lightning_client):
def test_send_coins(admin_stub, other_admin_stub):
new_address_response = admin_stub.LndNewAddress(ln.NewAddressRequest())
send_coins_response = lightning_client.send_coins(
new_address_response.address, 55555555
send_coins_response = send_coins(
other_admin_stub, new_address_response.address, 55555555
)
time.sleep(10)
get_transactions_response = admin_stub.LndGetTransactions(
@ -603,7 +604,6 @@ def test_buy_squeak(
admin_stub,
other_admin_stub,
connected_tcp_peer_id,
lightning_client,
signing_profile_id,
saved_squeak_hash,
):
@ -637,9 +637,9 @@ def test_buy_squeak(
offer = get_buy_offers_response.offers[0]
print("Tring to connect LND peer with offer: {}".format(offer))
with connect_peer(
lightning_client, offer.node_host, offer.node_pubkey
), open_channel(lightning_client, offer.node_pubkey, 1000000):
with peer_connection(
other_admin_stub, offer.node_host, offer.node_pubkey
), channel(other_admin_stub, offer.node_pubkey, 1000000):
print("Channel context manager opened.")
@ -759,7 +759,6 @@ def test_download_free_squeak(
admin_stub,
other_admin_stub,
connected_tcp_peer_id,
lightning_client,
signing_profile_id_with_free_price,
saved_squeak_hash,
):
@ -783,7 +782,6 @@ def test_download_single_squeak(
admin_stub,
other_admin_stub,
connected_tcp_peer_id,
lightning_client,
signing_profile_id,
saved_squeak_hash,
):
@ -999,7 +997,6 @@ def test_get_squeak_by_lookup(
admin_stub,
other_admin_stub,
connected_tcp_peer_id,
lightning_client,
signing_profile_id,
saved_squeak_hash,
):

View file

@ -27,13 +27,13 @@ import threading
import time
from contextlib import contextmanager
from lnd_lightning_client import LNDLightningClient
from squeak.core.elliptic import scalar_difference
from squeak.core.elliptic import scalar_from_bytes
from squeak.core.elliptic import scalar_to_bytes
from squeak.core.signing import CSigningKey
from squeak.core.signing import CSqueakAddress
from proto import lnd_pb2
from proto import squeak_admin_pb2
@ -47,30 +47,12 @@ def get_address(signing_key):
return str(address)
def get_latest_block_info(lightning_client):
get_info_response = lightning_client.get_info()
block_hash = bytes.fromhex(get_info_response.block_hash)
block_height = get_info_response.block_height
return block_hash, block_height
def get_hash(squeak):
""" Needs to be reversed because hash is stored as little-endian """
hash_bytes = squeak.GetHash()[::-1]
return hash_bytes.hex()
def load_lightning_client() -> LNDLightningClient:
tls_cert_path = "~/.lnd/tls.cert"
macaroon_path = "~/.lnd/data/chain/bitcoin/simnet/admin.macaroon"
return LNDLightningClient(
"lnd",
10009,
tls_cert_path,
macaroon_path,
)
def string_to_hex(s):
return bytes.fromhex(s)
@ -88,33 +70,57 @@ def bytes_to_base64_string(data: bytes) -> str:
@contextmanager
def connect_peer(lightning_client, lightning_host, remote_pubkey):
def peer_connection(node_stub, lightning_host, remote_pubkey):
# Connect the peer
lightning_client.connect_peer(remote_pubkey, lightning_host)
connect_peer(node_stub, remote_pubkey, lightning_host)
try:
print("Yielding lnd peer is connected.")
yield
finally:
# Disconnect the peer
lightning_client.disconnect_peer(
remote_pubkey,
)
disconnect_peer(node_stub, remote_pubkey)
time.sleep(2)
@contextmanager
def open_channel(lightning_client, remote_pubkey, amount):
def channel(node_stub, remote_pubkey, amount):
# Open channel to the server lightning node
pubkey_bytes = string_to_hex(remote_pubkey)
open_channel_response = lightning_client.open_channel(pubkey_bytes, amount)
# pubkey_bytes = string_to_hex(remote_pubkey)
# open_channel_response = lightning_client.open_channel(pubkey_bytes, amount)
print("Trying to open channel...")
channel_point = open_channel(node_stub, remote_pubkey, amount)
print("Opening channel...")
for update in open_channel_response:
if update.HasField("chan_open"):
channel_point = update.chan_open.channel_point
print("Channel now open: " + str(channel_point))
# Wait for channel to be open.
MAX_RETRIES = 30
i = 0
while True:
print("Try number: {}".format(i))
# peers_list = lightning_client.list_peers()
peers_list = list_peers(node_stub)
# channels_list = lightning_client.list_channels()
channels_list = list_channels(node_stub)
# pending_channels_list = lightning_client.pending_channels()
pending_channels_list = pending_channels(node_stub)
print("list peers: {}".format(peers_list))
print("list channels: {}".format(channels_list))
print("pending channels: {}".format(pending_channels_list))
if len(channels_list.channels) > 0:
print("Channel now open.")
break
print("list peers: {}".format(lightning_client.list_peers()))
print("list channels: {}".format(lightning_client.list_channels()))
print("pending channels: {}".format(lightning_client.pending_channels()))
time.sleep(1)
i += 1
if i > MAX_RETRIES:
raise Exception("Open channel timed out.")
# for update in open_channel_response:
# if update.HasField("chan_open"):
# channel_point = update.chan_open.channel_point
# print("Channel now open: " + str(channel_point))
# break
# print("list peers: {}".format(lightning_client.list_peers()))
# print("list channels: {}".format(lightning_client.list_channels()))
# print("pending channels: {}".format(lightning_client.pending_channels()))
time.sleep(10)
try:
yield
@ -122,10 +128,8 @@ def open_channel(lightning_client, remote_pubkey, amount):
# Code to release resource, e.g.:
# Close the channel
time.sleep(2)
for update in lightning_client.close_channel(channel_point):
if update.HasField("chan_close"):
print("Channel closed.")
break
# for update in lightning_client.close_channel(channel_point):
close_channel(node_stub, channel_point)
@contextmanager
@ -355,6 +359,71 @@ def delete_profile(node_stub, profile_id):
)
def open_channel(node_stub, remote_pubkey, amount):
return node_stub.LndOpenChannelSync(
lnd_pb2.OpenChannelRequest(
node_pubkey_string=remote_pubkey,
local_funding_amount=amount,
)
)
def close_channel(node_stub, channel_point):
return node_stub.LndCloseChannel(
lnd_pb2.CloseChannelRequest(
channel_point=channel_point,
)
)
def list_peers(node_stub):
return node_stub.LndListPeers(
lnd_pb2.ListPeersRequest()
)
def list_channels(node_stub):
return node_stub.LndListChannels(
lnd_pb2.ListChannelsRequest()
)
def pending_channels(node_stub):
return node_stub.LndPendingChannels(
lnd_pb2.PendingChannelsRequest()
)
def connect_peer(node_stub, pubkey, host):
lightning_address = lnd_pb2.LightningAddress(
pubkey=pubkey,
host=host,
)
connect_peer_request = lnd_pb2.ConnectPeerRequest(
addr=lightning_address,
)
return node_stub.LndConnectPeer(connect_peer_request)
def disconnect_peer(node_stub, pubkey):
disconnect_peer_request = lnd_pb2.DisconnectPeerRequest(
pub_key=pubkey,
)
return node_stub.LndDisconnectPeer(
disconnect_peer_request,
)
def send_coins(node_stub, addr, amount):
send_coins_request = lnd_pb2.SendCoinsRequest(
addr=addr,
amount=amount,
)
return node_stub.LndSendCoins(
send_coins_request,
)
@contextmanager
def subscribe_squeak_entry(node_stub, squeak_hash):
q = queue.Queue()

View file

@ -170,28 +170,19 @@ class SqueakCore:
# Calculate the preimage
preimage = add_tweak(secret_key, nonce)
# Create the lightning invoice
add_invoice_response = self.lightning_client.add_invoice(
invoice = self.lightning_client.create_invoice(
preimage, price_msat
)
payment_hash = add_invoice_response.r_hash
invoice_payment_request = add_invoice_response.payment_request
# invoice_expiry = add_invoice_response.expiry
lookup_invoice_response = self.lightning_client.lookup_invoice(
payment_hash.hex()
)
invoice_time = lookup_invoice_response.creation_date
invoice_expiry = lookup_invoice_response.expiry
# Save the incoming potential payment in the databse.
return SentOffer(
sent_offer_id=None,
squeak_hash=squeak_hash,
payment_hash=payment_hash,
secret_key=preimage,
payment_hash=invoice.r_hash,
secret_key=preimage, # TODO: remove this field.
nonce=nonce,
price_msat=price_msat,
payment_request=invoice_payment_request,
invoice_time=invoice_time,
invoice_expiry=invoice_expiry,
payment_request=invoice.payment_request,
invoice_time=invoice.creation_date,
invoice_expiry=invoice.expiry,
peer_address=peer_address,
)

View file

@ -0,0 +1,37 @@
# MIT License
#
# Copyright (c) 2020 Jonathan Zernik
#
# 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.
class LightningError(Exception):
"""Base class for other lightning exceptions."""
class LightningCommandError(LightningError):
"""Error that is raised when the lightning RPC fails."""
def __init__(self, err):
self.err = err
def __repr__(self):
return 'LightningCommandError(%r)' % (
self.err,
)

View file

@ -0,0 +1,33 @@
# MIT License
#
# Copyright (c) 2020 Jonathan Zernik
#
# 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.
from typing import NamedTuple
class Invoice(NamedTuple):
"""Represents a lightning invoice."""
r_hash: bytes
payment_request: str
value_msat: int
settled: bool
settle_index: int
creation_date: int
expiry: int

View file

@ -0,0 +1,46 @@
# MIT License
#
# Copyright (c) 2020 Jonathan Zernik
#
# 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.
import logging
from abc import ABC
from abc import abstractmethod
from squeaknode.lightning.invoice import Invoice
logger = logging.getLogger(__name__)
class LightningClient(ABC):
@abstractmethod
def create_invoice(self, preimage, amount_msat) -> Invoice:
"""Add a new invoice.
Args:
preimage: The lightning invoice preimage.
amount_msat: The amount of the invoice in msats.
Returns:
Invoice: an object representing a lightning invoice.
Raises:
LightningRequestError: If the request fails.
"""

View file

@ -27,6 +27,7 @@ import grpc
from proto import lnd_pb2
from proto import lnd_pb2_grpc
from squeaknode.lightning.invoice import Invoice
logger = logging.getLogger(__name__)
@ -48,16 +49,26 @@ class LNDLightningClient:
tls_cert_path: str,
macaroon_path: str,
) -> None:
url = "{}:{}".format(host, port)
self.host = host
self.port = port
self.tls_cert_path = tls_cert_path
self.macaroon_path = macaroon_path
self.stub = None
def init(self):
self.stub = self._get_stub()
def _get_stub(self):
url = "{}:{}".format(self.host, self.port)
# Lnd cert is at ~/.lnd/tls.cert on Linux and
# ~/Library/Application Support/Lnd/tls.cert on Mac
cert = open(os.path.expanduser(tls_cert_path), "rb").read()
cert = open(os.path.expanduser(self.tls_cert_path), "rb").read()
cert_creds = grpc.ssl_channel_credentials(cert)
# Lnd admin macaroon is at ~/.lnd/data/chain/bitcoin/simnet/admin.macaroon on Linux and
# ~/Library/Application Support/Lnd/data/chain/bitcoin/simnet/admin.macaroon on Mac
with open(os.path.expanduser(macaroon_path), "rb") as f:
with open(os.path.expanduser(self.macaroon_path), "rb") as f:
macaroon_bytes = f.read()
macaroon = codecs.encode(macaroon_bytes, "hex")
self.macaroon = codecs.encode(macaroon_bytes, "hex")
@ -76,7 +87,7 @@ class LNDLightningClient:
# finally pass in the combined credentials when creating a channel
channel = grpc.secure_channel(url, combined_creds)
self.stub = lnd_pb2_grpc.LightningStub(channel)
return lnd_pb2_grpc.LightningStub(channel)
def get_wallet_balance(self):
# Retrieve and display the wallet balance
@ -269,3 +280,19 @@ class LNDLightningClient:
r_hash_str=r_hash_str,
)
return self.stub.LookupInvoice(payment_hash)
def create_invoice(self, preimage, amount_msat) -> Invoice:
add_invoice_response = self.add_invoice(preimage, amount_msat)
payment_hash = add_invoice_response.r_hash
lookup_invoice_response = self.lookup_invoice(
payment_hash.hex()
)
return Invoice(
r_hash=lookup_invoice_response.r_hash,
payment_request=lookup_invoice_response.payment_request,
value_msat=amount_msat,
settled=lookup_invoice_response.settled,
settle_index=lookup_invoice_response.settle_index,
creation_date=lookup_invoice_response.creation_date,
expiry=lookup_invoice_response.expiry,
)

View file

@ -128,6 +128,7 @@ class SqueakNode:
self.config.lnd.tls_cert_path,
self.config.lnd.macaroon_path,
)
self.lightning_client.init()
def initialize_bitcoin_client(self):
# load the bitcoin client

View file

@ -0,0 +1,119 @@
# MIT License
#
# Copyright (c) 2020 Jonathan Zernik
#
# 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.
import mock
import pytest
from proto import lnd_pb2
from squeaknode.lightning.lnd_lightning_client import LNDLightningClient
from tests.utils import gen_random_hash
@pytest.fixture
def lnd_host():
yield "fake_lnd_host"
@pytest.fixture
def lnd_port():
yield 9876
@pytest.fixture
def tls_cert_path():
yield "fake_tls_cert_path"
@pytest.fixture
def macaroon_path():
yield "fake_macaroon_path"
@pytest.fixture
def preimage():
yield gen_random_hash()
@pytest.fixture
def price_msat():
yield 33333
@pytest.fixture
def rpc_invoice(preimage):
yield lnd_pb2.Invoice(
memo='hello',
r_preimage=preimage,
)
# @pytest.fixture
# def lnd_lightning_client_and_get_stub(lnd_host, lnd_port, tls_cert_path, macaroon_path):
# client = LNDLightningClient(
# host=lnd_host,
# port=lnd_port,
# tls_cert_path=tls_cert_path,
# macaroon_path=macaroon_path,
# )
# with mock.patch.object(client, '_get_stub', autospec=True) as mock_get_stub:
# yield client, mock_get_stub
# @pytest.fixture
# def lnd_lightning_client(lnd_lightning_client_and_get_stub):
# client, _ = lnd_lightning_client_and_get_stub
# yield client
# @pytest.fixture
# def mock_get_stub(lnd_lightning_client_and_get_stub):
# _, get_stub = lnd_lightning_client_and_get_stub
# yield get_stub
@pytest.fixture
def make_lightning_client(lnd_host, lnd_port, tls_cert_path, macaroon_path):
client = LNDLightningClient(
host=lnd_host,
port=lnd_port,
tls_cert_path=tls_cert_path,
macaroon_path=macaroon_path,
)
with mock.patch.object(client, '_get_stub', autospec=True) as mock_get_stub:
def fn(stub):
mock_get_stub.return_value = stub
client.init()
return client
yield fn
def test_add_invoice(make_lightning_client, preimage, price_msat, rpc_invoice):
mock_stub = mock.MagicMock()
mock_stub.AddInvoice.return_value = rpc_invoice
client = make_lightning_client(mock_stub)
add_invoice_response = client.add_invoice(
preimage, price_msat)
(call_invoice,) = mock_stub.AddInvoice.call_args.args
print(call_invoice)
assert call_invoice.r_preimage == preimage
assert call_invoice.value_msat == price_msat
assert add_invoice_response == rpc_invoice