lightning

This commit is contained in:
callebtc
2022-09-12 01:14:16 +03:00
parent 5df35156f3
commit 18d955e4f0
10 changed files with 374 additions and 55 deletions

2
cashu
View File

@@ -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:

3
lightning/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from lightning.lnbits import LNbitsWallet
WALLET = LNbitsWallet()

88
lightning/base.py Normal file
View File

@@ -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

150
lightning/lnbits.py Normal file
View File

@@ -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)

View File

@@ -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"])
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)}

View File

@@ -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_."""
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)
return [await self._generate_promise(a, B_) for a in split]
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."""

10
poetry.lock generated
View File

@@ -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"},

View File

@@ -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"

View File

@@ -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())

View File

@@ -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."""
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)
proofs = super().mint(split)
if proofs == []:
raise Exception("received no proofs")
new_proofs += proofs
await self._store_proofs(proofs)
self.proofs += new_proofs
return new_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)
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"
)
assert fst_proofs == []
assert snd_proofs == []
# 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"])]