From 18d955e4f0754cd84c91ac862f9c6f70c1973b46 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 12 Sep 2022 01:14:16 +0300 Subject: [PATCH] lightning --- cashu | 2 +- lightning/__init__.py | 3 + lightning/base.py | 88 +++++++++++++++++++++++++ lightning/lnbits.py | 150 ++++++++++++++++++++++++++++++++++++++++++ mint/app.py | 28 ++++++-- mint/ledger.py | 43 ++++++++++-- poetry.lock | 10 +-- pyproject.toml | 1 - test_wallet.py | 54 +++++++++++---- wallet/wallet.py | 50 +++++++------- 10 files changed, 374 insertions(+), 55 deletions(-) create mode 100644 lightning/__init__.py create mode 100644 lightning/base.py create mode 100644 lightning/lnbits.py diff --git a/cashu b/cashu index f22ebb8..3b1963a 100755 --- a/cashu +++ b/cashu @@ -31,7 +31,7 @@ def coro(f): @click.option("--invalidate", default="", help="Invalidate tokens.") @coro async def main(host, wallet, mint, send, receive, invalidate): - wallet = Wallet(host, f"data/{wallet}") + wallet = Wallet(host, f"data/{wallet}", wallet) await m001_initial(db=wallet.db) await wallet.load_proofs() if mint: diff --git a/lightning/__init__.py b/lightning/__init__.py new file mode 100644 index 0000000..baa53c7 --- /dev/null +++ b/lightning/__init__.py @@ -0,0 +1,3 @@ +from lightning.lnbits import LNbitsWallet + +WALLET = LNbitsWallet() diff --git a/lightning/base.py b/lightning/base.py new file mode 100644 index 0000000..e38b6d8 --- /dev/null +++ b/lightning/base.py @@ -0,0 +1,88 @@ +from abc import ABC, abstractmethod +from typing import AsyncGenerator, Coroutine, NamedTuple, Optional + + +class StatusResponse(NamedTuple): + error_message: Optional[str] + balance_msat: int + + +class InvoiceResponse(NamedTuple): + ok: bool + checking_id: Optional[str] = None # payment_hash, rpc_id + payment_request: Optional[str] = None + error_message: Optional[str] = None + + +class PaymentResponse(NamedTuple): + # when ok is None it means we don't know if this succeeded + ok: Optional[bool] = None + checking_id: Optional[str] = None # payment_hash, rcp_id + fee_msat: Optional[int] = None + preimage: Optional[str] = None + error_message: Optional[str] = None + + +class PaymentStatus(NamedTuple): + paid: Optional[bool] = None + fee_msat: Optional[int] = None + preimage: Optional[str] = None + + @property + def pending(self) -> bool: + return self.paid is not True + + @property + def failed(self) -> bool: + return self.paid == False + + def __str__(self) -> str: + if self.paid == True: + return "settled" + elif self.paid == False: + return "failed" + elif self.paid == None: + return "still pending" + else: + return "unknown (should never happen)" + + +class Wallet(ABC): + @abstractmethod + def status(self) -> Coroutine[None, None, StatusResponse]: + pass + + @abstractmethod + def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + ) -> Coroutine[None, None, InvoiceResponse]: + pass + + @abstractmethod + def pay_invoice( + self, bolt11: str, fee_limit_msat: int + ) -> Coroutine[None, None, PaymentResponse]: + pass + + @abstractmethod + def get_invoice_status( + self, checking_id: str + ) -> Coroutine[None, None, PaymentStatus]: + pass + + @abstractmethod + def get_payment_status( + self, checking_id: str + ) -> Coroutine[None, None, PaymentStatus]: + pass + + @abstractmethod + def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + pass + + +class Unsupported(Exception): + pass diff --git a/lightning/lnbits.py b/lightning/lnbits.py new file mode 100644 index 0000000..88d1cd8 --- /dev/null +++ b/lightning/lnbits.py @@ -0,0 +1,150 @@ +import asyncio +import hashlib +import json +from os import getenv +from typing import AsyncGenerator, Dict, Optional + +import requests + +from .base import ( + InvoiceResponse, + PaymentResponse, + PaymentStatus, + StatusResponse, + Wallet, +) + + +class LNbitsWallet(Wallet): + """https://github.com/lnbits/lnbits""" + + def __init__(self): + self.endpoint = getenv("LNBITS_ENDPOINT") + + key = getenv("LNBITS_KEY") + self.key = {"X-Api-Key": key} + self.s = requests.Session() + self.s.auth = ("user", "pass") + self.s.headers.update({"X-Api-Key": key}) + + async def status(self) -> StatusResponse: + try: + r = self.s.get(url=f"{self.endpoint}/api/v1/wallet", timeout=15) + except Exception as exc: + return StatusResponse( + f"Failed to connect to {self.endpoint} due to: {exc}", 0 + ) + + try: + data = r.json() + except: + return StatusResponse( + f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0 + ) + if "detail" in data: + return StatusResponse(f"LNbits error: {data['detail']}", 0) + return StatusResponse(None, data["balance"]) + + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + ) -> InvoiceResponse: + data: Dict = {"out": False, "amount": amount} + if description_hash: + data["description_hash"] = description_hash.hex() + if unhashed_description: + data["unhashed_description"] = unhashed_description.hex() + + data["memo"] = memo or "" + try: + r = self.s.post(url=f"{self.endpoint}/api/v1/payments", json=data) + except: + return InvoiceResponse(False, None, None, r.json()["detail"]) + ok, checking_id, payment_request, error_message = ( + True, + None, + None, + None, + ) + + data = r.json() + checking_id, payment_request = data["checking_id"], data["payment_request"] + + return InvoiceResponse(ok, checking_id, payment_request, error_message) + + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + try: + r = self.s.post( + url=f"{self.endpoint}/api/v1/payments", + json={"out": True, "bolt11": bolt11}, + timeout=None, + ) + except: + error_message = r.json()["detail"] + return PaymentResponse(None, None, None, None, error_message) + ok, checking_id, fee_msat, preimage, error_message = ( + True, + None, + None, + None, + None, + ) + + data = r.json() + checking_id = data["payment_hash"] + + # we do this to get the fee and preimage + payment: PaymentStatus = await self.get_payment_status(checking_id) + + return PaymentResponse(ok, checking_id, payment.fee_msat, payment.preimage) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + try: + + r = self.s.get( + url=f"{self.endpoint}/api/v1/payments/{checking_id}", + headers=self.key, + ) + except: + return PaymentStatus(None) + return PaymentStatus(r.json()["paid"]) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + try: + r = self.s.get( + url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key + ) + except: + return PaymentStatus(None) + data = r.json() + if "paid" not in data and "details" not in data: + return PaymentStatus(None) + + return PaymentStatus(data["paid"], data["details"]["fee"], data["preimage"]) + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + url = f"{self.endpoint}/api/v1/payments/sse" + + while True: + try: + async with requests.stream("GET", url) as r: + async for line in r.aiter_lines(): + if line.startswith("data:"): + try: + data = json.loads(line[5:]) + except json.decoder.JSONDecodeError: + continue + + if type(data) is not dict: + continue + + yield data["payment_hash"] # payment_hash + + except: + pass + + print("lost connection to lnbits /payments/sse, retrying in 5 seconds") + await asyncio.sleep(5) diff --git a/mint/app.py b/mint/app.py index f910cea..46a6204 100644 --- a/mint/app.py +++ b/mint/app.py @@ -7,6 +7,7 @@ import asyncio from mint.ledger import Ledger from mint.migrations import m001_initial +from lightning import WALLET # Ledger pubkey ledger = Ledger("supersecretprivatekey", "../data/mint") @@ -22,6 +23,16 @@ class MyFlaskApp(Flask): async def create_tasks_func(): await asyncio.wait([m001_initial(ledger.db)]) await ledger.load_used_proofs() + + error_message, balance = await WALLET.status() + if error_message: + print( + f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", + RuntimeWarning, + ) + + print(f"Lightning balance: {balance} sat") + print("Mint started.") loop = asyncio.get_event_loop() @@ -44,13 +55,18 @@ def keys(): @app.route("/mint", methods=["POST"]) async def mint(): - amount = int(request.args.get("amount")) or 64 - x = int(request.json["x"]) - y = int(request.json["y"]) - B_ = Point(x, y, secp256k1) + payload = request.json + amounts = [] + B_s = [] + for k, v in payload.items(): + amounts.append(v["amount"]) + x = int(v["x"]) + y = int(v["y"]) + B_ = Point(x, y, secp256k1) + B_s.append(B_) try: - promise = await ledger.mint(B_, amount) - return promise + promises = await ledger.mint(B_s, amounts) + return promises except Exception as exc: return {"error": str(exc)} diff --git a/mint/ledger.py b/mint/ledger.py index 2e84230..ba2adb9 100644 --- a/mint/ledger.py +++ b/mint/ledger.py @@ -3,6 +3,7 @@ Implementation of https://gist.github.com/phyro/935badc682057f418842c72961cf096c """ import hashlib +import time from ecc.curve import secp256k1, Point from ecc.key import gen_keypair @@ -12,6 +13,7 @@ from core.db import Database from core.split import amount_split from core.settings import MAX_ORDER from mint.crud import store_promise, invalidate_proof, get_proofs_used +from lightning import WALLET class Ledger: @@ -109,8 +111,27 @@ class Ledger: rv.append(2**pos) return rv - # Public methods + async def _request_lightning(self, amount): + error, balance = await WALLET.status() + if error: + raise Exception(f"Lightning wallet not responding: {error}") + ok, checking_id, payment_request, error_message = await WALLET.create_invoice( + amount, "cashu deposit" + ) + print(payment_request) + timeout = time.time() + 60 # 1 minute to pay invoice + while True: + status = await WALLET.get_invoice_status(checking_id) + if status.pending and time.time() > timeout: + print("Timeout") + return False + if not status.pending: + print("paid") + return True + time.sleep(5) + + # Public methods def get_pubkeys(self): """Returns public keys for possible amounts.""" return { @@ -118,12 +139,22 @@ class Ledger: for amt in [2**i for i in range(MAX_ORDER)] } - async def mint(self, B_, amount): + async def mint(self, B_s, amounts, lightning=True): """Mints a promise for coins for B_.""" - if amount not in [2**i for i in range(MAX_ORDER)]: - raise Exception(f"Can only mint amounts up to {2**MAX_ORDER}.") - split = amount_split(amount) - return [await self._generate_promise(a, B_) for a in split] + for amount in amounts: + if amount not in [2**i for i in range(MAX_ORDER)]: + raise Exception(f"Can only mint amounts up to {2**MAX_ORDER}.") + + if lightning: + paid = await self._request_lightning(sum(amounts)) + if not paid: + raise Exception(f"Did not receive payment in time.") + + promises = [] + for B_, amount in zip(B_s, amounts): + split = amount_split(amount) + promises += [await self._generate_promise(amount, B_) for a in split] + return promises async def split(self, proofs, amount, output_data): """Consumes proofs and prepares new promises based on the amount split.""" diff --git a/poetry.lock b/poetry.lock index c1800f4..396bfb2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -33,11 +33,11 @@ python-versions = ">=3.5" [[package]] name = "certifi" -version = "2021.10.8" +version = "2022.6.15.1" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "charset-normalizer" @@ -438,7 +438,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "e4b8f463f9ee65e2f74b6c03444e2ca2c46788de5f1f8f7bc5b1c0b1df5771e1" +content-hash = "d2c4df45eea8820487f68d4f8a5509956c1a114a0b65ebd7f707187253612a1a" [metadata.files] asgiref = [ @@ -454,8 +454,8 @@ bech32 = [ {file = "bech32-1.2.0.tar.gz", hash = "sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899"}, ] certifi = [ - {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, - {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, + {file = "certifi-2022.6.15.1-py3-none-any.whl", hash = "sha256:43dadad18a7f168740e66944e4fa82c6611848ff9056ad910f8f7a3e46ab89e0"}, + {file = "certifi-2022.6.15.1.tar.gz", hash = "sha256:cffdcd380919da6137f76633531a5817e3a9f268575c128249fb637e4f9e73fb"}, ] charset-normalizer = [ {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, diff --git a/pyproject.toml b/pyproject.toml index b916322..bb6752f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ requests = "2.27.1" pytest-asyncio = "0.19.0" SQLAlchemy = "1.3.24" sqlalchemy-aio = "0.17.0" -certifi = "2021.10.8" charset-normalizer = "2.0.12" click = "8.0.4" Flask = "2.2.2" diff --git a/test_wallet.py b/test_wallet.py index 92ecbf0..b7ff5fa 100644 --- a/test_wallet.py +++ b/test_wallet.py @@ -9,12 +9,27 @@ from wallet.migrations import m001_initial SERVER_ENDPOINT = "http://localhost:3338" +async def assert_err(f, msg): + """Compute f() and expect an error message 'msg'.""" + try: + await f + except Exception as exc: + assert exc.args[0] == msg, Exception( + f"Expected error: {msg}, got: {exc.args[0]}" + ) + + +def assert_amt(proofs, expected): + """Assert amounts the proofs contain.""" + assert [p["amount"] for p in proofs] == expected + + async def run_test(): - wallet1 = Wallet1(SERVER_ENDPOINT, "data/wallet1") + wallet1 = Wallet1(SERVER_ENDPOINT, "data/wallet1", "wallet1") await m001_initial(wallet1.db) wallet1.status() - wallet2 = Wallet1(SERVER_ENDPOINT, "data/wallet2") + wallet2 = Wallet1(SERVER_ENDPOINT, "data/wallet2", "wallet2") await m001_initial(wallet2.db) wallet2.status() @@ -30,17 +45,19 @@ async def run_test(): proofs += await wallet1.mint(63) assert wallet1.balance == 64 + 63 - # Error: We try to split by amount higher than available w1_fst_proofs, w1_snd_proofs = await wallet1.split(wallet1.proofs, 65) - # assert w1_fst_proofs == [] - # assert w1_snd_proofs == [] assert wallet1.balance == 63 + 64 wallet1.status() # Error: We try to double-spend by providing a valid proof twice - w1_fst_proofs, w1_snd_proofs = await wallet1.split(wallet1.proofs + proofs, 20) - assert w1_fst_proofs == [] - assert w1_snd_proofs == [] + # try: + # await wallet1.split(wallet1.proofs + proofs, 20), + # except Exception as exc: + # print(exc.args[0]) + await assert_err( + wallet1.split(wallet1.proofs + proofs, 20), + f"Error: Already spent. Secret: {proofs[0]['secret']}", + ) assert wallet1.balance == 63 + 64 wallet1.status() @@ -54,9 +71,11 @@ async def run_test(): wallet1.status() # Error: We try to double-spend and it fails - w1_fst_proofs2_fails, w1_snd_proofs2_fails = await wallet1.split([proofs[0]], 10) - assert w1_fst_proofs2_fails == [] - assert w1_snd_proofs2_fails == [] + await assert_err( + wallet1.split([proofs[0]], 10), + f"Error: Already spent. Secret: {proofs[0]['secret']}", + ) + assert wallet1.balance == 63 + 64 wallet1.status() @@ -81,15 +100,22 @@ async def run_test(): wallet1.status() # Error: We try to double-spend and it fails - w1_fst_proofs2, w1_snd_proofs2 = await wallet1.split(w1_snd_proofs, 5) - assert w1_fst_proofs2 == [] - assert w1_snd_proofs2 == [] + await assert_err( + wallet1.split(w1_snd_proofs, 5), + f"Error: Already spent. Secret: {w1_snd_proofs[0]['secret']}", + ) + assert wallet1.balance == 63 + 64 - 20 wallet1.status() assert wallet1.proof_amounts() == [1, 2, 4, 4, 32, 64] assert wallet2.proof_amounts() == [4, 16] + await assert_err( + wallet1.split(w1_snd_proofs, -500), + "Error: Invalid split amount: -500", + ) + if __name__ == "__main__": async_unwrap(run_test()) diff --git a/wallet/wallet.py b/wallet/wallet.py index 4c9aa48..7ba7bc1 100644 --- a/wallet/wallet.py +++ b/wallet/wallet.py @@ -53,18 +53,25 @@ class LedgerAPI: ) return proofs - def mint(self, amount): + def mint(self, amounts): """Mints new coins and returns a proof of promise.""" - secret = str(random.getrandbits(128)) - B_, r = b_dhke.step1_bob(secret) + payload = {} + secrets = [] + rs = [] + for i, amount in enumerate(amounts): + secret = str(random.getrandbits(128)) + secrets.append(secret) + B_, r = b_dhke.step1_bob(secret) + rs.append(r) + payload[i] = {"amount": amount, "x": str(B_.x), "y": str(B_.y)} + promises = requests.post( self.url + "/mint", - params={"amount": amount}, - json={"x": str(B_.x), "y": str(B_.y)}, + json=payload, ).json() if "error" in promises: raise Exception("Error: {}".format(promises["error"])) - return self._construct_proofs(promises, [(r, secret)]) + return self._construct_proofs(promises, [(r, s) for r, s in zip(rs, secrets)]) def split(self, proofs, amount): """Consume proofs and create new promises based on amount split.""" @@ -109,10 +116,11 @@ class LedgerAPI: class Wallet(LedgerAPI): """Minimal wallet wrapper.""" - def __init__(self, url: str, db: str): + def __init__(self, url: str, db: str, name: str = "no_name"): super().__init__(url) self.db = Database("wallet", db) self.proofs: List[Proof] = [] + self.name = name async def load_proofs(self): self.proofs = await get_proofs(db=self.db) @@ -123,15 +131,12 @@ class Wallet(LedgerAPI): async def mint(self, amount): split = amount_split(amount) - new_proofs = [] - for amount in split: - proofs = super().mint(amount) - if proofs == []: - raise Exception("received no proofs") - new_proofs += proofs - await self._store_proofs(proofs) - self.proofs += new_proofs - return new_proofs + proofs = super().mint(split) + if proofs == []: + raise Exception("received no proofs") + await self._store_proofs(proofs) + self.proofs += proofs + return proofs async def redeem(self, proofs): return await self.split(proofs, sum(p["amount"] for p in proofs)) @@ -153,11 +158,12 @@ class Wallet(LedgerAPI): async def invalidate(self, proofs): # first we make sure that the server has invalidated these proofs - fst_proofs, snd_proofs = await self.split( - proofs, sum(p["amount"] for p in proofs) - ) - assert fst_proofs == [] - assert snd_proofs == [] + try: + await self.split(proofs, sum(p["amount"] for p in proofs)) + except Exception as exc: + assert exc.args[0].startswith("Error: Already spent."), Exception( + "invalidating unspent tokens" + ) # TODO: check with server if they were redeemed already for proof in proofs: @@ -172,7 +178,7 @@ class Wallet(LedgerAPI): return sum(p["amount"] for p in self.proofs) def status(self): - print("Balance: {}".format(self.balance)) + print(f"{self.name} balance: {self.balance}") def proof_amounts(self): return [p["amount"] for p in sorted(self.proofs, key=lambda p: p["amount"])]