diff --git a/docker/test/Dockerfile b/docker/test/Dockerfile index e096b80b..76fefc46 100644 --- a/docker/test/Dockerfile +++ b/docker/test/Dockerfile @@ -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=. \ diff --git a/itests/tests/conftest.py b/itests/tests/conftest.py index d98bada9..c20d26d7 100644 --- a/itests/tests/conftest.py +++ b/itests/tests/conftest.py @@ -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 diff --git a/itests/tests/test_squeak_node.py b/itests/tests/test_squeak_node.py index 1b024449..dd6db904 100644 --- a/itests/tests/test_squeak_node.py +++ b/itests/tests/test_squeak_node.py @@ -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, ): diff --git a/itests/tests/util.py b/itests/tests/util.py index 557b6a89..e988d463 100644 --- a/itests/tests/util.py +++ b/itests/tests/util.py @@ -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() diff --git a/squeaknode/core/squeak_core.py b/squeaknode/core/squeak_core.py index 9f5f62e2..0d5e3883 100644 --- a/squeaknode/core/squeak_core.py +++ b/squeaknode/core/squeak_core.py @@ -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, ) diff --git a/squeaknode/lightning/exception.py b/squeaknode/lightning/exception.py new file mode 100644 index 00000000..ecf6901c --- /dev/null +++ b/squeaknode/lightning/exception.py @@ -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, + ) diff --git a/squeaknode/lightning/invoice.py b/squeaknode/lightning/invoice.py new file mode 100644 index 00000000..a5c71b38 --- /dev/null +++ b/squeaknode/lightning/invoice.py @@ -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 diff --git a/squeaknode/lightning/lightning_client.py b/squeaknode/lightning/lightning_client.py new file mode 100644 index 00000000..a3a52150 --- /dev/null +++ b/squeaknode/lightning/lightning_client.py @@ -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. + """ diff --git a/squeaknode/lightning/lnd_lightning_client.py b/squeaknode/lightning/lnd_lightning_client.py index f0509a52..3ff8dbe0 100644 --- a/squeaknode/lightning/lnd_lightning_client.py +++ b/squeaknode/lightning/lnd_lightning_client.py @@ -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, + ) diff --git a/squeaknode/node/squeak_node.py b/squeaknode/node/squeak_node.py index d90d8943..aad9e0e1 100644 --- a/squeaknode/node/squeak_node.py +++ b/squeaknode/node/squeak_node.py @@ -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 diff --git a/tests/lightning/test_lnd_lightning_client.py b/tests/lightning/test_lnd_lightning_client.py new file mode 100644 index 00000000..18c24f58 --- /dev/null +++ b/tests/lightning/test_lnd_lightning_client.py @@ -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