Merge pull request #41 from callebtc/invoices/save_in_db

save lightning invoices
This commit is contained in:
calle
2022-10-18 18:26:46 +02:00
committed by GitHub
11 changed files with 249 additions and 147 deletions

9
.github/codecov.yml vendored Normal file
View File

@@ -0,0 +1,9 @@
coverage:
status:
patch: off
project:
default:
target: auto
# adjust accordingly based on how flaky your tests are
# this allows a 10% drop from the previous base commit coverage
threshold: 10%

View File

@@ -106,7 +106,7 @@ cashu info
Returns: Returns:
```bash ```bash
Version: 0.4.0 Version: 0.4.1
Debug: False Debug: False
Cashu dir: /home/user/.cashu Cashu dir: /home/user/.cashu
Wallet: wallet Wallet: wallet

View File

@@ -12,15 +12,6 @@ class P2SHScript(BaseModel):
signature: str signature: str
address: Union[str, None] = None address: Union[str, None] = None
@classmethod
def from_row(cls, row: Row):
return cls(
address=row[0],
script=row[1],
signature=row[2],
used=row[3],
)
class Proof(BaseModel): class Proof(BaseModel):
id: str = "" id: str = ""
@@ -28,36 +19,10 @@ class Proof(BaseModel):
secret: str = "" secret: str = ""
C: str = "" C: str = ""
script: Union[P2SHScript, None] = None script: Union[P2SHScript, None] = None
reserved: bool = False # whether this proof is reserved for sending reserved: Union[None, bool] = False # whether this proof is reserved for sending
send_id: str = "" # unique ID of send attempt send_id: Union[None, str] = "" # unique ID of send attempt
time_created: str = "" time_created: Union[None, str] = ""
time_reserved: str = "" time_reserved: Union[None, str] = ""
@classmethod
def from_row(cls, row: Row):
return cls(
amount=row[0],
C=row[1],
secret=row[2],
reserved=row[3] or False,
send_id=row[4] or "",
time_created=row[5] or "",
time_reserved=row[6] or "",
id=row[7] or "",
)
@classmethod
def from_dict(cls, d: dict):
assert "amount" in d, "no amount in proof"
return cls(
amount=d.get("amount"),
C=d.get("C"),
secret=d.get("secret") or "",
reserved=d.get("reserved") or False,
send_id=d.get("send_id") or "",
time_created=d.get("time_created") or "",
time_reserved=d.get("time_reserved") or "",
)
def to_dict(self): def to_dict(self):
return dict(id=self.id, amount=self.amount, secret=self.secret, C=self.C) return dict(id=self.id, amount=self.amount, secret=self.secret, C=self.C)
@@ -81,17 +46,12 @@ class Proofs(BaseModel):
class Invoice(BaseModel): class Invoice(BaseModel):
amount: int amount: int
pr: str pr: str
hash: str hash: Union[None, str] = None
issued: bool = False preimage: Union[str, None] = None
issued: Union[None, bool] = False
@classmethod paid: Union[None, bool] = False
def from_row(cls, row: Row): time_created: Union[None, str, int, float] = ""
return cls( time_paid: Union[None, str, int, float] = ""
amount=int(row[0]),
pr=str(row[1]),
hash=str(row[2]),
issued=bool(row[3]),
)
class BlindedMessage(BaseModel): class BlindedMessage(BaseModel):
@@ -104,14 +64,6 @@ class BlindedSignature(BaseModel):
amount: int amount: int
C_: str C_: str
@classmethod
def from_dict(cls, d: dict):
return cls(
id=d.get("id"),
amount=d["amount"],
C_=d["C_"],
)
class MintRequest(BaseModel): class MintRequest(BaseModel):
blinded_messages: List[BlindedMessage] = [] blinded_messages: List[BlindedMessage] = []
@@ -173,16 +125,6 @@ class KeyBase(BaseModel):
amount: int amount: int
pubkey: str pubkey: str
@classmethod
def from_row(cls, row: Row):
if row is None:
return cls
return cls(
id=row[0],
amount=int(row[1]),
pubkey=row[2],
)
class WalletKeyset: class WalletKeyset:
id: str id: str
@@ -213,19 +155,6 @@ class WalletKeyset:
self.public_keys = pubkeys self.public_keys = pubkeys
self.id = derive_keyset_id(self.public_keys) self.id = derive_keyset_id(self.public_keys)
@classmethod
def from_row(cls, row: Row):
if row is None:
return cls
return cls(
id=row[0],
mint_url=row[1],
valid_from=row[2],
valid_to=row[3],
first_seen=row[4],
active=row[5],
)
class MintKeyset: class MintKeyset:
id: str id: str
@@ -266,20 +195,6 @@ class MintKeyset:
self.public_keys = derive_pubkeys(self.private_keys) self.public_keys = derive_pubkeys(self.private_keys)
self.id = derive_keyset_id(self.public_keys) self.id = derive_keyset_id(self.public_keys)
@classmethod
def from_row(cls, row: Row):
if row is None:
return cls
return cls(
id=row[0],
derivation_path=row[1],
valid_from=row[2],
valid_to=row[3],
first_seen=row[4],
active=row[5],
version=row[6],
)
def get_keybase(self): def get_keybase(self):
return { return {
k: KeyBase(id=self.id, amount=k, pubkey=v.serialize().hex()) k: KeyBase(id=self.id, amount=k, pubkey=v.serialize().hex())

View File

@@ -14,7 +14,7 @@ if os.path.isfile(ENV_FILE):
env.read_env(ENV_FILE) env.read_env(ENV_FILE)
else: else:
ENV_FILE = "" ENV_FILE = ""
env.read_env() env.read_env(recurse=False)
DEBUG = env.bool("DEBUG", default=False) DEBUG = env.bool("DEBUG", default=False)
if not DEBUG: if not DEBUG:
@@ -48,4 +48,4 @@ LNBITS_ENDPOINT = env.str("LNBITS_ENDPOINT", default=None)
LNBITS_KEY = env.str("LNBITS_KEY", default=None) LNBITS_KEY = env.str("LNBITS_KEY", default=None)
MAX_ORDER = 64 MAX_ORDER = 64
VERSION = "0.4.0" VERSION = "0.4.1"

View File

@@ -135,7 +135,7 @@ async def get_lightning_invoice(
""", """,
(hash,), (hash,),
) )
return Invoice.from_row(row) return Invoice(**row)
async def update_lightning_invoice( async def update_lightning_invoice(
@@ -204,4 +204,4 @@ async def get_keyset(
""", """,
tuple(values), tuple(values),
) )
return [MintKeyset.from_row(row) for row in rows] return [MintKeyset(**row) for row in rows]

View File

@@ -3,7 +3,6 @@
import asyncio import asyncio
import base64 import base64
import json import json
import math
import os import os
import sys import sys
import time import time
@@ -17,14 +16,16 @@ from os.path import isdir, join
import click import click
from loguru import logger from loguru import logger
import cashu.core.bolt11 as bolt11
from cashu.core.base import Proof from cashu.core.base import Proof
from cashu.core.bolt11 import Invoice, decode from cashu.core.helpers import sum_proofs
from cashu.core.helpers import fee_reserve, sum_proofs
from cashu.core.migrations import migrate_databases from cashu.core.migrations import migrate_databases
from cashu.core.settings import CASHU_DIR, DEBUG, ENV_FILE, LIGHTNING, MINT_URL, VERSION from cashu.core.settings import CASHU_DIR, DEBUG, ENV_FILE, LIGHTNING, MINT_URL, VERSION
from cashu.wallet import migrations from cashu.wallet import migrations
from cashu.wallet.crud import get_reserved_proofs, get_unused_locks from cashu.wallet.crud import (
get_lightning_invoices,
get_reserved_proofs,
get_unused_locks,
)
from cashu.wallet.wallet import Wallet as Wallet from cashu.wallet.wallet import Wallet as Wallet
@@ -85,14 +86,14 @@ async def invoice(ctx, amount: int, hash: str):
if not LIGHTNING: if not LIGHTNING:
r = await wallet.mint(amount) r = await wallet.mint(amount)
elif amount and not hash: elif amount and not hash:
r = await wallet.request_mint(amount) invoice = await wallet.request_mint(amount)
if "pr" in r: if invoice.pr:
print(f"Pay invoice to mint {amount} sat:") print(f"Pay invoice to mint {amount} sat:")
print("") print("")
print(f"Invoice: {r['pr']}") print(f"Invoice: {invoice.pr}")
print("") print("")
print( print(
f"Execute this command if you abort the check:\ncashu invoice {amount} --hash {r['hash']}" f"Execute this command if you abort the check:\ncashu invoice {amount} --hash {invoice.hash}"
) )
check_until = time.time() + 5 * 60 # check for five minutes check_until = time.time() + 5 * 60 # check for five minutes
print("") print("")
@@ -105,7 +106,7 @@ async def invoice(ctx, amount: int, hash: str):
while time.time() < check_until and not paid: while time.time() < check_until and not paid:
time.sleep(3) time.sleep(3)
try: try:
await wallet.mint(amount, r["hash"]) await wallet.mint(amount, invoice.hash)
paid = True paid = True
print(" Invoice paid.") print(" Invoice paid.")
except Exception as e: except Exception as e:
@@ -130,17 +131,10 @@ async def pay(ctx, invoice: str, yes: bool):
wallet: Wallet = ctx.obj["WALLET"] wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint() await wallet.load_mint()
wallet.status() wallet.status()
decoded_invoice: Invoice = bolt11.decode(invoice) amount, fees = await wallet.get_pay_amount_with_fees(invoice)
# check if it's an internal payment
fees = (await wallet.check_fees(invoice))["fee"]
amount = math.ceil(
(decoded_invoice.amount_msat + fees * 1000) / 1000
) # 1% fee for Lightning
if not yes: if not yes:
click.confirm( click.confirm(
f"Pay {decoded_invoice.amount_msat//1000} sat ({amount} sat incl. fees)?", f"Pay {amount - fees} sat ({amount} sat incl. fees)?",
abort=True, abort=True,
default=True, default=True,
) )
@@ -221,7 +215,7 @@ async def receive(ctx, coin: str, lock: str):
signature = p2shscripts[0].signature signature = p2shscripts[0].signature
else: else:
script, signature = None, None script, signature = None, None
proofs = [Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(coin))] proofs = [Proof(**p) for p in json.loads(base64.urlsafe_b64decode(coin))]
_, _ = await wallet.redeem(proofs, scnd_script=script, scnd_siganture=signature) _, _ = await wallet.redeem(proofs, scnd_script=script, scnd_siganture=signature)
wallet.status() wallet.status()
@@ -250,9 +244,7 @@ async def burn(ctx, coin: str, all: bool, force: bool):
proofs = wallet.proofs proofs = wallet.proofs
else: else:
# check only the specified ones # check only the specified ones
proofs = [ proofs = [Proof(**p) for p in json.loads(base64.urlsafe_b64decode(coin))]
Proof.from_dict(p) for p in json.loads(base64.urlsafe_b64decode(coin))
]
wallet.status() wallet.status()
await wallet.invalidate(proofs) await wallet.invalidate(proofs)
wallet.status() wallet.status()
@@ -330,6 +322,41 @@ async def locks(ctx):
return True return True
@cli.command("invoices", help="List of all pending invoices.")
@click.pass_context
@coro
async def invoices(ctx):
wallet: Wallet = ctx.obj["WALLET"]
invoices = await get_lightning_invoices(db=wallet.db)
if len(invoices):
print("")
print(f"--------------------------\n")
for invoice in invoices:
print(f"Paid: {invoice.paid}")
print(f"Incoming: {invoice.amount > 0}")
print(f"Amount: {abs(invoice.amount)}")
if invoice.hash:
print(f"Hash: {invoice.hash}")
if invoice.preimage:
print(f"Preimage: {invoice.preimage}")
if invoice.time_created:
d = datetime.utcfromtimestamp(
int(float(invoice.time_created))
).strftime("%Y-%m-%d %H:%M:%S")
print(f"Created: {d}")
if invoice.time_paid:
d = datetime.utcfromtimestamp(int(float(invoice.time_paid))).strftime(
"%Y-%m-%d %H:%M:%S"
)
print(f"Paid: {d}")
print("")
print(f"Payment request: {invoice.pr}")
print("")
print(f"--------------------------\n")
else:
print("No invoices found.")
@cli.command("wallets", help="List of all available wallets.") @cli.command("wallets", help="List of all available wallets.")
@click.pass_context @click.pass_context
@coro @coro

View File

@@ -1,7 +1,7 @@
import time import time
from typing import Any, List, Optional from typing import Any, List, Optional
from cashu.core.base import KeyBase, P2SHScript, Proof, WalletKeyset from cashu.core.base import Invoice, KeyBase, P2SHScript, Proof, WalletKeyset
from cashu.core.db import Connection, Database from cashu.core.db import Connection, Database
@@ -31,7 +31,7 @@ async def get_proofs(
SELECT * from proofs SELECT * from proofs
""" """
) )
return [Proof.from_row(r) for r in rows] return [Proof(**dict(r)) for r in rows]
async def get_reserved_proofs( async def get_reserved_proofs(
@@ -45,7 +45,7 @@ async def get_reserved_proofs(
WHERE reserved WHERE reserved
""" """
) )
return [Proof.from_row(r) for r in rows] return [Proof(**r) for r in rows]
async def invalidate_proof( async def invalidate_proof(
@@ -162,7 +162,7 @@ async def get_unused_locks(
""", """,
tuple(args), tuple(args),
) )
return [P2SHScript.from_row(r) for r in rows] return [P2SHScript(**r) for r in rows]
async def update_p2sh_used( async def update_p2sh_used(
@@ -233,4 +233,104 @@ async def get_keyset(
""", """,
tuple(values), tuple(values),
) )
return WalletKeyset.from_row(row) if row is not None else None return WalletKeyset(**row) if row is not None else None
async def store_lightning_invoice(
db: Database,
invoice: Invoice,
conn: Optional[Connection] = None,
):
await (conn or db).execute(
f"""
INSERT INTO invoices
(amount, pr, hash, preimage, paid, time_created, time_paid)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
invoice.amount,
invoice.pr,
invoice.hash,
invoice.preimage,
invoice.paid,
invoice.time_created,
invoice.time_paid,
),
)
async def get_lightning_invoice(
db: Database,
hash: str = None,
conn: Optional[Connection] = None,
):
clauses = []
values: List[Any] = []
if hash:
clauses.append("hash = ?")
values.append(hash)
where = ""
if clauses:
where = f"WHERE {' AND '.join(clauses)}"
row = await (conn or db).fetchone(
f"""
SELECT * from invoices
{where}
""",
tuple(values),
)
return Invoice(**row)
async def get_lightning_invoices(
db: Database,
paid: bool = None,
conn: Optional[Connection] = None,
):
clauses: List[Any] = []
values: List[Any] = []
if paid is not None:
clauses.append("paid = ?")
values.append(paid)
where = ""
if clauses:
where = f"WHERE {' AND '.join(clauses)}"
rows = await (conn or db).fetchall(
f"""
SELECT * from invoices
{where}
""",
tuple(values),
)
return [Invoice(**r) for r in rows]
async def update_lightning_invoice(
db: Database,
hash: str,
paid: bool,
time_paid: int = None,
conn: Optional[Connection] = None,
):
clauses = []
values: List[Any] = []
clauses.append("paid = ?")
values.append(paid)
if time_paid:
clauses.append("time_paid = ?")
values.append(time_paid)
await (conn or db).execute(
f"UPDATE invoices SET {', '.join(clauses)} WHERE hash = ?",
(
*values,
hash,
),
)

View File

@@ -119,18 +119,28 @@ async def m005_wallet_keysets(db: Database):
); );
""" """
) )
# await db.execute(
# f"""
# CREATE TABLE IF NOT EXISTS mint_pubkeys (
# id TEXT NOT NULL,
# amount INTEGER NOT NULL,
# pubkey TEXT NOT NULL,
# UNIQUE (id, pubkey)
# );
# """
# )
await db.execute("ALTER TABLE proofs ADD COLUMN id TEXT") await db.execute("ALTER TABLE proofs ADD COLUMN id TEXT")
await db.execute("ALTER TABLE proofs_used ADD COLUMN id TEXT") await db.execute("ALTER TABLE proofs_used ADD COLUMN id TEXT")
async def m006_invoices(db: Database):
"""
Stores Lightning invoices.
"""
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS invoices (
amount INTEGER NOT NULL,
pr TEXT NOT NULL,
hash TEXT,
preimage TEXT,
paid BOOL DEFAULT FALSE,
time_created TIMESTAMP DEFAULT {db.timestamp_now},
time_paid TIMESTAMP DEFAULT {db.timestamp_now},
UNIQUE (hash)
);
"""
)

View File

@@ -1,6 +1,8 @@
import base64 import base64
import json import json
import math
import secrets as scrts import secrets as scrts
import time
import uuid import uuid
from itertools import groupby from itertools import groupby
from typing import Dict, List from typing import Dict, List
@@ -9,11 +11,13 @@ import requests
from loguru import logger from loguru import logger
import cashu.core.b_dhke as b_dhke import cashu.core.b_dhke as b_dhke
import cashu.core.bolt11 as bolt11
from cashu.core.base import ( from cashu.core.base import (
BlindedMessage, BlindedMessage,
BlindedSignature, BlindedSignature,
CheckFeesRequest, CheckFeesRequest,
CheckRequest, CheckRequest,
Invoice,
MeltRequest, MeltRequest,
MintRequest, MintRequest,
P2SHScript, P2SHScript,
@@ -21,6 +25,7 @@ from cashu.core.base import (
SplitRequest, SplitRequest,
WalletKeyset, WalletKeyset,
) )
from cashu.core.bolt11 import Invoice as InvoiceBolt11
from cashu.core.db import Database from cashu.core.db import Database
from cashu.core.helpers import sum_proofs from cashu.core.helpers import sum_proofs
from cashu.core.script import ( from cashu.core.script import (
@@ -38,8 +43,10 @@ from cashu.wallet.crud import (
invalidate_proof, invalidate_proof,
secret_used, secret_used,
store_keyset, store_keyset,
store_lightning_invoice,
store_p2sh, store_p2sh,
store_proof, store_proof,
update_lightning_invoice,
update_proof_reserved, update_proof_reserved,
) )
@@ -175,7 +182,7 @@ class LedgerAPI:
resp.raise_for_status() resp.raise_for_status()
return_dict = resp.json() return_dict = resp.json()
self.raise_on_error(return_dict) self.raise_on_error(return_dict)
return return_dict return Invoice(amount=amount, pr=return_dict["pr"], hash=return_dict["hash"])
async def mint(self, amounts, payment_hash=None): async def mint(self, amounts, payment_hash=None):
"""Mints new coins and returns a proof of promise.""" """Mints new coins and returns a proof of promise."""
@@ -192,7 +199,7 @@ class LedgerAPI:
promises_list = resp.json() promises_list = resp.json()
self.raise_on_error(promises_list) self.raise_on_error(promises_list)
promises = [BlindedSignature.from_dict(p) for p in promises_list] promises = [BlindedSignature(**p) for p in promises_list]
return self._construct_proofs(promises, secrets, rs) return self._construct_proofs(promises, secrets, rs)
async def split(self, proofs, amount, scnd_secret: str = None): async def split(self, proofs, amount, scnd_secret: str = None):
@@ -246,8 +253,8 @@ class LedgerAPI:
promises_dict = resp.json() promises_dict = resp.json()
self.raise_on_error(promises_dict) self.raise_on_error(promises_dict)
promises_fst = [BlindedSignature.from_dict(p) for p in promises_dict["fst"]] promises_fst = [BlindedSignature(**p) for p in promises_dict["fst"]]
promises_snd = [BlindedSignature.from_dict(p) for p in promises_dict["snd"]] promises_snd = [BlindedSignature(**p) for p in promises_dict["snd"]]
# Construct proofs from promises (i.e., unblind signatures) # Construct proofs from promises (i.e., unblind signatures)
frst_proofs = self._construct_proofs( frst_proofs = self._construct_proofs(
promises_fst, secrets[: len(promises_fst)], rs[: len(promises_fst)] promises_fst, secrets[: len(promises_fst)], rs[: len(promises_fst)]
@@ -340,7 +347,10 @@ class Wallet(LedgerAPI):
return {key: list(group) for key, group in groupby(proofs, lambda p: p.id)} return {key: list(group) for key, group in groupby(proofs, lambda p: p.id)}
async def request_mint(self, amount): async def request_mint(self, amount):
return super().request_mint(amount) invoice = super().request_mint(amount)
invoice.time_created = int(time.time())
await store_lightning_invoice(db=self.db, invoice=invoice)
return invoice
async def mint(self, amount: int, payment_hash: str = None): async def mint(self, amount: int, payment_hash: str = None):
split = amount_split(amount) split = amount_split(amount)
@@ -348,6 +358,10 @@ class Wallet(LedgerAPI):
if proofs == []: if proofs == []:
raise Exception("received no proofs.") raise Exception("received no proofs.")
await self._store_proofs(proofs) await self._store_proofs(proofs)
if payment_hash:
await update_lightning_invoice(
db=self.db, hash=payment_hash, paid=True, time_paid=int(time.time())
)
self.proofs += proofs self.proofs += proofs
return proofs return proofs
@@ -389,6 +403,14 @@ class Wallet(LedgerAPI):
status = await super().pay_lightning(proofs, invoice) status = await super().pay_lightning(proofs, invoice)
if status["paid"] == True: if status["paid"] == True:
await self.invalidate(proofs) await self.invalidate(proofs)
invoice_obj = Invoice(
amount=-sum_proofs(proofs),
pr=invoice,
preimage=status.get("preimage"),
paid=True,
time_paid=time.time(),
)
await store_lightning_invoice(db=self.db, invoice=invoice_obj)
else: else:
raise Exception("could not pay invoice.") raise Exception("could not pay invoice.")
return status["paid"] return status["paid"]
@@ -417,6 +439,25 @@ class Wallet(LedgerAPI):
proofs = [p for p in proofs if not p.reserved] proofs = [p for p in proofs if not p.reserved]
return proofs return proofs
async def get_pay_amount_with_fees(self, invoice: str):
"""
Decodes the amount from a Lightning invoice and returns the
total amount (amount+fees) to be paid.
"""
decoded_invoice: InvoiceBolt11 = bolt11.decode(invoice)
# check if it's an internal payment
fees = int((await self.check_fees(invoice))["fee"])
amount = math.ceil((decoded_invoice.amount_msat + fees * 1000) / 1000) # 1% fee
return amount, fees
async def split_to_pay(self, invoice: str):
"""
Splits proofs such that a Lightning invoice can be paid.
"""
amount, _ = await self.get_pay_amount_with_fees(invoice)
_, send_proofs = await self.split_to_send(self.proofs, amount)
return send_proofs
async def split_to_send( async def split_to_send(
self, self,
proofs: List[Proof], proofs: List[Proof],

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "cashu" name = "cashu"
version = "0.4.0" version = "0.4.1"
description = "Ecash wallet and mint." description = "Ecash wallet and mint."
authors = ["calle <callebtc@protonmail.com>"] authors = ["calle <callebtc@protonmail.com>"]
license = "MIT" license = "MIT"

View File

@@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cashu.wallet.cli:cli"]}
setuptools.setup( setuptools.setup(
name="cashu", name="cashu",
version="0.4.0", version="0.4.1",
description="Ecash wallet and mint with Bitcoin Lightning support", description="Ecash wallet and mint with Bitcoin Lightning support",
long_description=long_description, long_description=long_description,
long_description_content_type="text/markdown", long_description_content_type="text/markdown",