Merge pull request #36 from callebtc/lnbits_importable_2

Importable lib
This commit is contained in:
calle
2022-10-15 01:28:10 +02:00
committed by GitHub
20 changed files with 459 additions and 227 deletions

View File

@@ -24,9 +24,9 @@ class P2SHScript(BaseModel):
class Proof(BaseModel):
id: str = ""
amount: int
amount: int = 0
secret: str = ""
C: str
C: str = ""
script: Union[P2SHScript, None] = None
reserved: bool = False # whether this proof is reserved for sending
send_id: str = "" # unique ID of send attempt
@@ -95,7 +95,6 @@ class Invoice(BaseModel):
class BlindedMessage(BaseModel):
id: str = ""
amount: int
B_: str
@@ -262,6 +261,7 @@ class MintKeyset:
self.generate_keys(seed)
def generate_keys(self, seed):
"""Generates keys of a keyset from a seed."""
self.private_keys = derive_keys(seed, self.derivation_path)
self.public_keys = derive_pubkeys(self.private_keys)
self.id = derive_keyset_id(self.public_keys)

View File

@@ -5,6 +5,14 @@ from typing import Dict, List
from cashu.core.secp import PrivateKey, PublicKey
from cashu.core.settings import MAX_ORDER
# entropy = bytes([random.getrandbits(8) for i in range(16)])
# mnemonic = bip39.mnemonic_from_bytes(entropy)
# seed = bip39.mnemonic_to_seed(mnemonic)
# root = bip32.HDKey.from_seed(seed, version=NETWORKS["main"]["xprv"])
# bip44_xprv = root.derive("m/44h/1h/0h")
# bip44_xpub = bip44_xprv.to_public()
def derive_keys(master_key: str, derivation_path: str = ""):
"""
@@ -29,7 +37,9 @@ def derive_pubkeys(keys: Dict[int, PrivateKey]):
def derive_keyset_id(keys: Dict[int, PublicKey]):
"""Deterministic derivation keyset_id from set of public keys."""
pubkeys_concat = "".join([p.serialize().hex() for _, p in keys.items()])
# sort public keys by amount
sorted_keys = dict(sorted(keys.items()))
pubkeys_concat = "".join([p.serialize().hex() for _, p in sorted_keys.items()])
return base64.b64encode(
hashlib.sha256((pubkeys_concat).encode("utf-8")).digest()
).decode()[:12]

View File

@@ -3,13 +3,17 @@ import re
from cashu.core.db import COCKROACH, POSTGRES, SQLITE, Database
def table_with_schema(db, table: str):
return f"{db.references_schema if db.schema else ''}{table}"
async def migrate_databases(db: Database, migrations_module):
"""Creates the necessary databases if they don't exist already; or migrates them."""
async def set_migration_version(conn, db_name, version):
await conn.execute(
"""
INSERT INTO dbversions (db, version) VALUES (?, ?)
f"""
INSERT INTO {table_with_schema(db, 'dbversions')} (db, version) VALUES (?, ?)
ON CONFLICT (db) DO UPDATE SET version = ?
""",
(db_name, version, version),
@@ -18,7 +22,7 @@ async def migrate_databases(db: Database, migrations_module):
async def run_migration(db, migrations_module):
db_name = migrations_module.__name__.split(".")[-2]
for key, migrate in migrations_module.__dict__.items():
match = match = matcher.match(key)
match = matcher.match(key)
if match:
version = int(match.group(1))
if version > current_versions.get(db_name, 0):
@@ -33,17 +37,19 @@ async def migrate_databases(db: Database, migrations_module):
async with db.connect() as conn:
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
f"SELECT * FROM sqlite_master WHERE type='table' AND name='{table_with_schema(db, 'dbversions')}'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_name = 'dbversions'"
f"SELECT * FROM information_schema.tables WHERE table_name = '{table_with_schema(db, 'dbversions')}'"
)
if not exists:
await migrations_module.m000_create_migrations_table(conn)
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
rows = await (
await conn.execute(f"SELECT * FROM {table_with_schema(db, 'dbversions')}")
).fetchall()
current_versions = {row["db"]: row["version"] for row in rows}
matcher = re.compile(r"^m(\d\d\d)_")
await run_migration(conn, migrations_module)
await run_migration(db, migrations_module)

View File

@@ -48,4 +48,4 @@ LNBITS_ENDPOINT = env.str("LNBITS_ENDPOINT", default=None)
LNBITS_KEY = env.str("LNBITS_KEY", default=None)
MAX_ORDER = 64
VERSION = "0.3.3"
VERSION = "0.4.0"

View File

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

View File

@@ -1,4 +1 @@
from cashu.core.settings import MINT_PRIVATE_KEY
from cashu.mint.ledger import Ledger
ledger = Ledger(MINT_PRIVATE_KEY, "data/mint", derivation_path="0/0/0/0")

View File

@@ -5,24 +5,25 @@ from fastapi import FastAPI
from loguru import logger
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette_context import context
from starlette_context.middleware import RawContextMiddleware
from cashu.core.settings import DEBUG, VERSION
from .router import router
from .startup import load_ledger
from .startup import start_mint_init
# from starlette_context import context
# from starlette_context.middleware import RawContextMiddleware
class CustomHeaderMiddleware(BaseHTTPMiddleware):
"""
Middleware for starlette that can set the context from request headers
"""
# class CustomHeaderMiddleware(BaseHTTPMiddleware):
# """
# Middleware for starlette that can set the context from request headers
# """
async def dispatch(self, request, call_next):
context["client-version"] = request.headers.get("Client-version")
response = await call_next(request)
return response
# async def dispatch(self, request, call_next):
# context["client-version"] = request.headers.get("Client-version")
# response = await call_next(request)
# return response
def create_app(config_object="core.settings") -> FastAPI:
@@ -60,12 +61,12 @@ def create_app(config_object="core.settings") -> FastAPI:
configure_logger()
middleware = [
Middleware(
RawContextMiddleware,
),
Middleware(CustomHeaderMiddleware),
]
# middleware = [
# Middleware(
# RawContextMiddleware,
# ),
# Middleware(CustomHeaderMiddleware),
# ]
app = FastAPI(
title="Cashu Mint",
@@ -75,7 +76,7 @@ def create_app(config_object="core.settings") -> FastAPI:
"name": "MIT License",
"url": "https://raw.githubusercontent.com/callebtc/cashu/main/LICENSE",
},
middleware=middleware,
# middleware=middleware,
)
return app
@@ -86,5 +87,5 @@ app.include_router(router=router)
@app.on_event("startup")
async def startup_load_ledger():
await load_ledger()
async def startup_mint():
await start_mint_init()

View File

@@ -2,19 +2,60 @@ from typing import Any, List, Optional
from cashu.core.base import Invoice, MintKeyset, Proof
from cashu.core.db import Connection, Database
from cashu.core.migrations import table_with_schema
class LedgerCrud:
"""
Database interface for Cashu mint.
This class needs to be overloaded by any app that imports the Cashu mint.
"""
async def get_keyset(*args, **kwags):
return await get_keyset(*args, **kwags)
async def get_lightning_invoice(*args, **kwags):
return await get_lightning_invoice(*args, **kwags)
async def get_proofs_used(*args, **kwags):
return await get_proofs_used(*args, **kwags)
async def invalidate_proof(*args, **kwags):
return await invalidate_proof(*args, **kwags)
async def store_keyset(*args, **kwags):
return await store_keyset(*args, **kwags)
async def store_lightning_invoice(*args, **kwags):
return await store_lightning_invoice(*args, **kwags)
async def store_promise(*args, **kwags):
return await store_promise(*args, **kwags)
async def update_lightning_invoice(*args, **kwags):
return await update_lightning_invoice(*args, **kwags)
async def store_promise(
db: Database,
amount: int,
B_: str,
C_: str,
db: Database,
conn: Optional[Connection] = None,
):
await (conn or db).execute(
"""
INSERT INTO promises
f"""
INSERT INTO {table_with_schema(db, 'promises')}
(amount, B_b, C_b)
VALUES (?, ?, ?)
""",
@@ -32,23 +73,23 @@ async def get_proofs_used(
):
rows = await (conn or db).fetchall(
"""
SELECT secret from proofs_used
f"""
SELECT secret from {table_with_schema(db, 'proofs_used')}
"""
)
return [row[0] for row in rows]
async def invalidate_proof(
proof: Proof,
db: Database,
proof: Proof,
conn: Optional[Connection] = None,
):
# we add the proof and secret to the used list
await (conn or db).execute(
"""
INSERT INTO proofs_used
f"""
INSERT INTO {table_with_schema(db, 'proofs_used')}
(amount, C, secret)
VALUES (?, ?, ?)
""",
@@ -61,14 +102,14 @@ async def invalidate_proof(
async def store_lightning_invoice(
invoice: Invoice,
db: Database,
invoice: Invoice,
conn: Optional[Connection] = None,
):
await (conn or db).execute(
"""
INSERT INTO invoices
f"""
INSERT INTO {table_with_schema(db, 'invoices')}
(amount, pr, hash, issued)
VALUES (?, ?, ?, ?)
""",
@@ -82,14 +123,14 @@ async def store_lightning_invoice(
async def get_lightning_invoice(
hash: str,
db: Database,
hash: str,
conn: Optional[Connection] = None,
):
row = await (conn or db).fetchone(
"""
SELECT * from invoices
f"""
SELECT * from {table_with_schema(db, 'invoices')}
WHERE hash = ?
""",
(hash,),
@@ -98,13 +139,13 @@ async def get_lightning_invoice(
async def update_lightning_invoice(
db: Database,
hash: str,
issued: bool,
db: Database,
conn: Optional[Connection] = None,
):
await (conn or db).execute(
"UPDATE invoices SET issued = ? WHERE hash = ?",
f"UPDATE {table_with_schema(db, 'invoices')} SET issued = ? WHERE hash = ?",
(
issued,
hash,
@@ -113,23 +154,23 @@ async def update_lightning_invoice(
async def store_keyset(
db: Database,
keyset: MintKeyset,
db: Database = None,
conn: Optional[Connection] = None,
):
await (conn or db).execute( # type: ignore
"""
INSERT INTO keysets
f"""
INSERT INTO {table_with_schema(db, 'keysets')}
(id, derivation_path, valid_from, valid_to, first_seen, active, version)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
keyset.id,
keyset.derivation_path,
keyset.valid_from,
keyset.valid_to,
keyset.first_seen,
keyset.valid_from or db.timestamp_now,
keyset.valid_to or db.timestamp_now,
keyset.first_seen or db.timestamp_now,
True,
keyset.version,
),
@@ -137,9 +178,9 @@ async def store_keyset(
async def get_keyset(
db: Database,
id: str = None,
derivation_path: str = "",
db: Database = None,
conn: Optional[Connection] = None,
):
clauses = []
@@ -158,7 +199,7 @@ async def get_keyset(
rows = await (conn or db).fetchall( # type: ignore
f"""
SELECT * from keysets
SELECT * from {table_with_schema(db, 'keysets')}
{where}
""",
tuple(values),

View File

@@ -6,7 +6,6 @@ import math
from typing import Dict, List, Set
from loguru import logger
from starlette_context import context
import cashu.core.b_dhke as b_dhke
import cashu.core.bolt11 as bolt11
@@ -19,79 +18,88 @@ from cashu.core.base import (
MintKeysets,
Proof,
)
from cashu.core.crypto import derive_keys, derive_keyset_id, derive_pubkeys
from cashu.core.db import Database
from cashu.core.helpers import fee_reserve, sum_proofs
from cashu.core.script import verify_script
from cashu.core.secp import PublicKey
from cashu.core.settings import LIGHTNING, MAX_ORDER, VERSION
from cashu.core.split import amount_split
from cashu.lightning import WALLET
from cashu.mint.crud import (
get_keyset,
get_lightning_invoice,
get_proofs_used,
invalidate_proof,
store_keyset,
store_lightning_invoice,
store_promise,
update_lightning_invoice,
)
from cashu.mint.crud import LedgerCrud
# from starlette_context import context
class Ledger:
def __init__(self, secret_key: str, db: str, derivation_path=""):
def __init__(
self,
db: Database,
seed: str,
derivation_path="",
crud=LedgerCrud,
lightning=None,
):
self.proofs_used: Set[str] = set()
self.master_key = secret_key
self.master_key = seed
self.derivation_path = derivation_path
self.db: Database = Database("mint", db)
self.db = db
self.crud = crud
self.lightning = lightning
async def load_used_proofs(self):
"""Load all used proofs from database."""
self.proofs_used = set(await get_proofs_used(db=self.db))
proofs_used = await self.crud.get_proofs_used(db=self.db)
self.proofs_used = set(proofs_used)
async def init_keysets(self):
"""Loads all past keysets and stores the active one if not already in db"""
# generate current keyset from seed and current derivation path
self.keyset = MintKeyset(
seed=self.master_key, derivation_path=self.derivation_path, version=VERSION
async def load_keyset(self, derivation_path):
"""Load current keyset keyset or generate new one."""
keyset = MintKeyset(
seed=self.master_key, derivation_path=derivation_path, version=VERSION
)
# check if current keyset is stored in db and store if not
logger.debug(f"Loading keyset {self.keyset.id} from db.")
current_keyset_local: List[MintKeyset] = await get_keyset(
id=self.keyset.id, db=self.db
logger.debug(f"Loading keyset {keyset.id} from db.")
tmp_keyset_local: List[MintKeyset] = await self.crud.get_keyset(
id=keyset.id, db=self.db
)
if not len(current_keyset_local):
logger.debug(f"Storing keyset {self.keyset.id}.")
await store_keyset(keyset=self.keyset, db=self.db)
if not len(tmp_keyset_local):
logger.debug(f"Storing keyset {keyset.id}.")
await self.crud.store_keyset(keyset=keyset, db=self.db)
return keyset
async def init_keysets(self):
"""Loads all keysets from db."""
self.keyset = await self.load_keyset(self.derivation_path)
# load all past keysets from db
# this needs two steps because the types of tmp_keysets and the argument of MintKeysets() are different
tmp_keysets: List[MintKeyset] = await get_keyset(db=self.db)
tmp_keysets: List[MintKeyset] = await self.crud.get_keyset(db=self.db)
self.keysets = MintKeysets(tmp_keysets)
logger.debug(f"Keysets {self.keysets.keysets}")
logger.debug(f"Loading {len(self.keysets.keysets)} keysets form db.")
# generate all derived keys from stored derivation paths of past keysets
for _, v in self.keysets.keysets.items():
logger.debug(f"Generating keys for keyset {v.id}")
v.generate_keys(self.master_key)
if len(self.keysets.keysets):
logger.debug(f"Loaded {len(self.keysets.keysets)} keysets from db.")
async def _generate_promises(self, amounts: List[int], B_s: List[str]):
async def _generate_promises(
self, B_s: List[BlindedMessage], keyset: MintKeyset = None
):
"""Generates promises that sum to the given amount."""
return [
await self._generate_promise(amount, PublicKey(bytes.fromhex(B_), raw=True))
for (amount, B_) in zip(amounts, B_s)
await self._generate_promise(
b.amount, PublicKey(bytes.fromhex(b.B_), raw=True), keyset
)
for b in B_s
]
async def _generate_promise(self, amount: int, B_: PublicKey):
async def _generate_promise(
self, amount: int, B_: PublicKey, keyset: MintKeyset = None
):
"""Generates a promise for given amount and returns a pair (amount, C')."""
secret_key = self.keyset.private_keys[amount] # Get the correct key
C_ = b_dhke.step2_bob(B_, secret_key)
await store_promise(
amount, B_=B_.serialize().hex(), C_=C_.serialize().hex(), db=self.db
keyset = keyset if keyset else self.keyset
private_key_amount = keyset.private_keys[amount]
C_ = b_dhke.step2_bob(B_, private_key_amount)
await self.crud.store_promise(
amount=amount, B_=B_.serialize().hex(), C_=C_.serialize().hex(), db=self.db
)
return BlindedSignature(amount=amount, C_=C_.serialize().hex())
return BlindedSignature(id=keyset.id, amount=amount, C_=C_.serialize().hex())
def _check_spendable(self, proof: Proof):
"""Checks whether the proof was already spent."""
@@ -109,22 +117,24 @@ class Ledger:
raise Exception(f"tokens already spent. Secret: {proof.secret}")
# if no keyset id is given in proof, assume the current one
if not proof.id:
secret_key = self.keyset.private_keys[proof.amount]
private_key_amount = self.keyset.private_keys[proof.amount]
else:
# use the appropriate active keyset for this proof.id
secret_key = self.keysets.keysets[proof.id].private_keys[proof.amount]
private_key_amount = self.keysets.keysets[proof.id].private_keys[
proof.amount
]
C = PublicKey(bytes.fromhex(proof.C), raw=True)
# backwards compatibility with old hash_to_curve < 0.3.3
# backwards compatibility with old hash_to_curve < 0.4.0
try:
ret = legacy.verify_pre_0_3_3(secret_key, C, proof.secret)
ret = legacy.verify_pre_0_3_3(private_key_amount, C, proof.secret)
if ret:
return ret
except:
pass
return b_dhke.verify(secret_key, C, proof.secret)
return b_dhke.verify(private_key_amount, C, proof.secret)
def _verify_script(self, idx: int, proof: Proof):
"""
@@ -209,17 +219,22 @@ class Ledger:
async def _request_lightning_invoice(self, amount: int):
"""Returns an invoice from the Lightning backend."""
error, balance = await WALLET.status()
error, balance = await self.lightning.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"
)
(
ok,
checking_id,
payment_request,
error_message,
) = await self.lightning.create_invoice(amount, "cashu deposit")
return payment_request, checking_id
async def _check_lightning_invoice(self, amounts, payment_hash: str):
"""Checks with the Lightning backend whether an invoice with this payment_hash was paid."""
invoice: Invoice = await get_lightning_invoice(payment_hash, db=self.db)
invoice: Invoice = await self.crud.get_lightning_invoice(
hash=payment_hash, db=self.db
)
if invoice.issued:
raise Exception("tokens already issued for this invoice.")
total_requested = sum(amounts)
@@ -227,19 +242,25 @@ class Ledger:
raise Exception(
f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}"
)
status = await WALLET.get_invoice_status(payment_hash)
status = await self.lightning.get_invoice_status(payment_hash)
if status.paid:
await update_lightning_invoice(payment_hash, issued=True, db=self.db)
await self.crud.update_lightning_invoice(
hash=payment_hash, issued=True, db=self.db
)
return status.paid
async def _pay_lightning_invoice(self, invoice: str, fees_msat: int):
"""Returns an invoice from the Lightning backend."""
error, _ = await WALLET.status()
error, _ = await self.lightning.status()
if error:
raise Exception(f"Lightning wallet not responding: {error}")
ok, checking_id, fee_msat, preimage, error_message = await WALLET.pay_invoice(
invoice, fee_limit_msat=fees_msat
)
(
ok,
checking_id,
fee_msat,
preimage,
error_message,
) = await self.lightning.pay_invoice(invoice, fee_limit_msat=fees_msat)
return ok, preimage
async def _invalidate_proofs(self, proofs: List[Proof]):
@@ -249,15 +270,12 @@ class Ledger:
self.proofs_used |= proof_msgs
# store in db
for p in proofs:
await invalidate_proof(p, db=self.db)
def _serialize_pubkeys(self):
"""Returns public keys for possible amounts."""
return {a: p.serialize().hex() for a, p in self.keyset.public_keys.items()}
await self.crud.invalidate_proof(proof=p, db=self.db)
# Public methods
def get_keyset(self):
return self._serialize_pubkeys()
def get_keyset(self, keyset_id: str = None):
keyset = self.keysets.keysets[keyset_id] if keyset_id else self.keyset
return {a: p.serialize().hex() for a, p in keyset.public_keys.items()}
async def request_mint(self, amount):
"""Returns Lightning invoice and stores it in the db."""
@@ -267,11 +285,17 @@ class Ledger:
)
if not payment_request or not checking_id:
raise Exception(f"Could not create Lightning invoice.")
await store_lightning_invoice(invoice, db=self.db)
await self.crud.store_lightning_invoice(invoice=invoice, db=self.db)
return payment_request, checking_id
async def mint(self, B_s: List[PublicKey], amounts: List[int], payment_hash=None):
async def mint(
self,
B_s: List[BlindedMessage],
payment_hash=None,
keyset: MintKeyset = None,
):
"""Mints a promise for coins for B_."""
amounts = [b.amount for b in B_s]
# check if lightning invoice was paid
if LIGHTNING:
try:
@@ -285,9 +309,7 @@ class Ledger:
if amount not in [2**i for i in range(MAX_ORDER)]:
raise Exception(f"Can only mint amounts up to {2**MAX_ORDER}.")
promises = [
await self._generate_promise(amount, B_) for B_, amount in zip(B_s, amounts)
]
promises = await self._generate_promises(B_s, keyset)
return promises
async def melt(self, proofs: List[Proof], invoice: str):
@@ -304,7 +326,10 @@ class Ledger:
"provided proofs not enough for Lightning payment."
)
status, preimage = await self._pay_lightning_invoice(invoice, fees_msat)
if LIGHTNING:
status, preimage = await self._pay_lightning_invoice(invoice, fees_msat)
else:
status, preimage = True, "preimage"
if status == True:
await self._invalidate_proofs(proofs)
return status, preimage
@@ -319,13 +344,20 @@ class Ledger:
amount = math.ceil(decoded_invoice.amount_msat / 1000)
# hack: check if it's internal, if it exists, it will return paid = False,
# if id does not exist (not internal), it returns paid = None
paid = await WALLET.get_invoice_status(decoded_invoice.payment_hash)
internal = paid.paid == False
if LIGHTNING:
paid = await self.lightning.get_invoice_status(decoded_invoice.payment_hash)
internal = paid.paid == False
else:
internal = True
fees_msat = fee_reserve(amount * 1000, internal)
return fees_msat
async def split(
self, proofs: List[Proof], amount: int, outputs: List[BlindedMessage]
self,
proofs: List[Proof],
amount: int,
outputs: List[BlindedMessage],
keyset: MintKeyset = None,
):
"""Consumes proofs and prepares new promises based on the amount split."""
total = sum_proofs(proofs)
@@ -355,13 +387,16 @@ class Ledger:
# Mark proofs as used and prepare new promises
await self._invalidate_proofs(proofs)
# split outputs according to amount
outs_fst = amount_split(total - amount)
outs_snd = amount_split(amount)
B_fst = [od.B_ for od in outputs[: len(outs_fst)]]
B_snd = [od.B_ for od in outputs[len(outs_fst) :]]
B_fst = [od for od in outputs[: len(outs_fst)]]
B_snd = [od for od in outputs[len(outs_fst) :]]
# generate promises
prom_fst, prom_snd = await self._generate_promises(
outs_fst, B_fst
), await self._generate_promises(outs_snd, B_snd)
B_fst, keyset
), await self._generate_promises(B_snd, keyset)
# verify amounts in produced proofs
self._verify_equation_balanced(proofs, prom_fst + prom_snd)
return prom_fst, prom_snd

View File

@@ -22,7 +22,8 @@ def main(
ssl_keyfile: str = None,
ssl_certfile: str = None,
):
"""Launched with `poetry run mint` at root level"""
"""This routine starts the uvicorn server if the Cashu mint is
launched with `poetry run mint` at root level"""
# this beautiful beast parses all command line arguments and passes them to the uvicorn server
d = dict()
for a in ctx.args:

View File

@@ -1,10 +1,11 @@
from cashu.core.db import Database
from cashu.core.migrations import table_with_schema
async def m000_create_migrations_table(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS dbversions (
f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'dbversions')} (
db TEXT PRIMARY KEY,
version INT NOT NULL
)
@@ -14,8 +15,8 @@ async def m000_create_migrations_table(db):
async def m001_initial(db: Database):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS promises (
f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'promises')} (
amount INTEGER NOT NULL,
B_b TEXT NOT NULL,
C_b TEXT NOT NULL,
@@ -27,8 +28,8 @@ async def m001_initial(db: Database):
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS proofs_used (
f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_used')} (
amount INTEGER NOT NULL,
C TEXT NOT NULL,
secret TEXT NOT NULL,
@@ -40,8 +41,8 @@ async def m001_initial(db: Database):
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS invoices (
f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'invoices')} (
amount INTEGER NOT NULL,
pr TEXT NOT NULL,
hash TEXT NOT NULL,
@@ -53,38 +54,38 @@ async def m001_initial(db: Database):
"""
)
await db.execute(
"""
CREATE VIEW IF NOT EXISTS balance_issued AS
SELECT COALESCE(SUM(s), 0) AS balance FROM (
SELECT SUM(amount) AS s
FROM promises
WHERE amount > 0
);
"""
)
# await db.execute(
# f"""
# CREATE VIEW {table_with_schema(db, 'balance_issued')} AS
# SELECT COALESCE(SUM(s), 0) AS balance FROM (
# SELECT SUM(amount) AS s
# FROM {table_with_schema(db, 'promises')}
# WHERE amount > 0
# );
# """
# )
await db.execute(
"""
CREATE VIEW IF NOT EXISTS balance_used AS
SELECT COALESCE(SUM(s), 0) AS balance FROM (
SELECT SUM(amount) AS s
FROM proofs_used
WHERE amount > 0
);
"""
)
# await db.execute(
# f"""
# CREATE VIEW {table_with_schema(db, 'balance_used')} AS
# SELECT COALESCE(SUM(s), 0) AS balance FROM (
# SELECT SUM(amount) AS s
# FROM {table_with_schema(db, 'proofs_used')}
# WHERE amount > 0
# );
# """
# )
await db.execute(
"""
CREATE VIEW IF NOT EXISTS balance AS
SELECT s_issued - s_used AS balance FROM (
SELECT bi.balance AS s_issued, bu.balance AS s_used
FROM balance_issued bi
CROSS JOIN balance_used bu
);
"""
)
# await db.execute(
# f"""
# CREATE VIEW {table_with_schema(db, 'balance')} AS
# SELECT s_issued - s_used AS balance FROM (
# SELECT bi.balance AS s_issued, bu.balance AS s_used
# FROM {table_with_schema(db, 'balance_issued')} bi
# CROSS JOIN {table_with_schema(db, 'balance_used')} bu
# );
# """
# )
async def m003_mint_keysets(db: Database):
@@ -93,12 +94,12 @@ async def m003_mint_keysets(db: Database):
"""
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS keysets (
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'keysets')} (
id TEXT NOT NULL,
derivation_path TEXT,
valid_from TIMESTAMP DEFAULT {db.timestamp_now},
valid_to TIMESTAMP DEFAULT {db.timestamp_now},
first_seen TIMESTAMP DEFAULT {db.timestamp_now},
valid_from TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
valid_to TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
first_seen TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
active BOOL DEFAULT TRUE,
UNIQUE (derivation_path)
@@ -108,7 +109,7 @@ async def m003_mint_keysets(db: Database):
)
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS mint_pubkeys (
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'mint_pubkeys')} (
id TEXT NOT NULL,
amount INTEGER NOT NULL,
pubkey TEXT NOT NULL,
@@ -124,4 +125,6 @@ async def m004_keysets_add_version(db: Database):
"""
Column that remembers with which version
"""
await db.execute("ALTER TABLE keysets ADD COLUMN version TEXT")
await db.execute(
f"ALTER TABLE {table_with_schema(db, 'keysets')} ADD COLUMN version TEXT"
)

View File

@@ -16,19 +16,20 @@ from cashu.core.base import (
SplitRequest,
)
from cashu.core.errors import CashuError
from cashu.mint import ledger
from cashu.mint.startup import ledger
router: APIRouter = APIRouter()
@router.get("/keys")
def keys() -> dict[int, str]:
async def keys() -> dict[int, str]:
"""Get the public keys of the mint"""
return ledger.get_keyset()
keyset = ledger.get_keyset()
return keyset
@router.get("/keysets")
def keysets() -> dict[str, list[str]]:
async def keysets() -> dict[str, list[str]]:
"""Get all active keysets of the mint"""
return {"keysets": ledger.keysets.get_ids()}
@@ -49,7 +50,7 @@ async def request_mint(amount: int = 0) -> GetMintResponse:
@router.post("/mint")
async def mint(
payloads: MintRequest,
mint_request: MintRequest,
payment_hash: Union[str, None] = None,
) -> Union[List[BlindedSignature], CashuError]:
"""
@@ -57,13 +58,10 @@ async def mint(
Call this endpoint after `GET /mint`.
"""
amounts = []
B_s = []
for payload in payloads.blinded_messages:
amounts.append(payload.amount)
B_s.append(PublicKey(bytes.fromhex(payload.B_), raw=True))
try:
promises = await ledger.mint(B_s, amounts, payment_hash=payment_hash)
promises = await ledger.mint(
mint_request.blinded_messages, payment_hash=payment_hash
)
return promises
except Exception as exc:
return CashuError(error=str(exc))

View File

@@ -1,26 +1,37 @@
# startup routine of the standalone app. These are the steps that need
# to be taken by external apps importing the cashu mint.
import asyncio
from loguru import logger
from cashu.core.db import Database
from cashu.core.migrations import migrate_databases
from cashu.core.settings import CASHU_DIR, LIGHTNING
from cashu.lightning import WALLET
from cashu.core.settings import CASHU_DIR, LIGHTNING, MINT_PRIVATE_KEY
from cashu.lightning.lnbits import LNbitsWallet
from cashu.mint import migrations
from cashu.mint.ledger import Ledger
from . import ledger
ledger = Ledger(
db=Database("mint", "data/mint"),
seed=MINT_PRIVATE_KEY,
# seed="asd",
derivation_path="0/0/0/0",
lightning=LNbitsWallet() if LIGHTNING else None,
)
async def load_ledger():
async def start_mint_init():
await migrate_databases(ledger.db, migrations)
# await asyncio.wait([m001_initial(ledger.db)])
await ledger.load_used_proofs()
await ledger.init_keysets()
if LIGHTNING:
error_message, balance = await WALLET.status()
error_message, balance = await ledger.lightning.status()
if error_message:
logger.warning(
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
f"The backend for {ledger.lightning.__class__.__name__} isn't working properly: '{error_message}'",
RuntimeWarning,
)
logger.info(f"Lightning balance: {balance} sat")

View File

@@ -173,7 +173,7 @@ class LedgerAPI:
async def _get_keysets(self, url):
resp = self.s.get(
url + "/keysets",
).json()
)
resp.raise_for_status()
keysets = resp.json()
assert len(keysets), Exception("did not receive any keysets")
@@ -273,9 +273,16 @@ class LedgerAPI:
Cheks whether the secrets in proofs are already spent or not and returns a list of booleans.
"""
payload = CheckRequest(proofs=proofs)
def _check_spendable_include_fields(proofs):
"""strips away fields from the model that aren't necessary for the /split"""
return {
"proofs": {i: {"secret"} for i in range(len(proofs))},
}
resp = self.s.post(
self.url + "/check",
json=payload.dict(),
json=payload.dict(include=_check_spendable_include_fields(proofs)),
)
resp.raise_for_status()
return_dict = resp.json()

19
poetry.lock generated
View File

@@ -168,7 +168,7 @@ starlette = "0.19.1"
all = ["email_validator (>=1.1.1,<2.0.0)", "itsdangerous (>=1.1.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "orjson (>=3.2.1,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "pyyaml (>=5.3.1,<7.0.0)", "requests (>=2.24.0,<3.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)"]
dev = ["autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<6.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "pre-commit (>=2.17.0,<3.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)"]
doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer (>=0.4.1,<0.5.0)"]
test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==22.3.0)", "databases[sqlite] (>=0.3.3,<0.6.0)", "email_validator (>=1.1.1,<2.0.0)", "flake8 (>=3.8.3,<6.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "orjson (>=3.2.1,<4.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "requests (>=2.24.0,<3.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "types-dataclasses (==0.6.5)", "types-orjson (==3.6.2)", "types-ujson (==4.2.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"]
test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==22.3.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "email_validator (>=1.1.1,<2.0.0)", "flake8 (>=3.8.3,<6.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "orjson (>=3.2.1,<4.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "requests (>=2.24.0,<3.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "types-dataclasses (==0.6.5)", "types-orjson (==3.6.2)", "types-ujson (==4.2.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"]
[[package]]
name = "h11"
@@ -553,17 +553,6 @@ typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""
[package.extras]
full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"]
[[package]]
name = "starlette-context"
version = "0.3.4"
description = "Access context in Starlette"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
starlette = "*"
[[package]]
name = "tomli"
version = "2.0.1"
@@ -643,7 +632,7 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>=
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
content-hash = "14ff9c57ca971c645f1a075b5c6fa0a84a38eaf6399d14afa724136728a3da03"
content-hash = "b4e980ee90226bab07750b1becc8c69df7752f6d168d200a79c782aa1efe61da"
[metadata.files]
anyio = [
@@ -1010,10 +999,6 @@ starlette = [
{file = "starlette-0.19.1-py3-none-any.whl", hash = "sha256:5a60c5c2d051f3a8eb546136aa0c9399773a689595e099e0877704d5888279bf"},
{file = "starlette-0.19.1.tar.gz", hash = "sha256:c6d21096774ecb9639acad41b86b7706e52ba3bf1dc13ea4ed9ad593d47e24c7"},
]
starlette-context = [
{file = "starlette_context-0.3.4-py37-none-any.whl", hash = "sha256:b16bf17bd3ead7ded2f458aebf7f913744b9cf28305e16c69b435a6c6ddf1135"},
{file = "starlette_context-0.3.4.tar.gz", hash = "sha256:2d28e1838302fb5d5adacadc10fb73fb2d5cca1f0aa1e279698701cc96f1567c"},
]
tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "cashu"
version = "0.3.3"
version = "0.4.0"
description = "Ecash wallet and mint."
authors = ["calle <callebtc@protonmail.com>"]
license = "MIT"
@@ -22,7 +22,6 @@ bitstring = "^3.1.9"
secp256k1 = "^0.14.0"
sqlalchemy-aio = "^0.17.0"
python-bitcoinlib = "^0.11.2"
starlette-context = "^0.3.4"
[tool.poetry.dev-dependencies]
black = {version = "^22.8.0", allow-prereleases = true}

View File

@@ -34,7 +34,6 @@ six==1.16.0 ; python_version >= "3.7" and python_version < "4.0"
sniffio==1.3.0 ; python_version >= "3.7" and python_version < "4.0"
sqlalchemy-aio==0.17.0 ; python_version >= "3.7" and python_version < "4.0"
sqlalchemy==1.3.24 ; python_version >= "3.7" and python_version < "4.0"
starlette-context==0.3.4 ; python_version >= "3.7" and python_version < "4.0"
starlette==0.19.1 ; python_version >= "3.7" and python_version < "4.0"
tomli==2.0.1 ; python_version >= "3.7" and python_version < "4.0"
typing-extensions==4.3.0 ; python_version >= "3.7" and python_version < "4.0"

View File

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

119
tests/test_mint.py Normal file
View File

@@ -0,0 +1,119 @@
import time
from typing import List
import pytest
import pytest_asyncio
from cashu.core.base import BlindedMessage, Proof
from cashu.core.helpers import async_unwrap, sum_proofs
from cashu.core.migrations import migrate_databases
SERVER_ENDPOINT = "http://localhost:3338"
import os
from cashu.core.db import Database
from cashu.core.settings import MAX_ORDER, MINT_PRIVATE_KEY
from cashu.lightning.lnbits import LNbitsWallet
from cashu.mint import migrations
from cashu.mint.ledger import Ledger
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: List[Proof], expected: int):
"""Assert amounts the proofs contain."""
assert [p.amount for p in proofs] == expected
async def start_mint_init(ledger):
await migrate_databases(ledger.db, migrations)
await ledger.load_used_proofs()
await ledger.init_keysets()
@pytest_asyncio.fixture(scope="function")
async def ledger():
db_file = "data/mint/test.sqlite3"
if os.path.exists(db_file):
os.remove(db_file)
ledger = Ledger(
db=Database("test", "data/mint"),
seed="TEST_PRIVATE_KEY",
derivation_path="0/0/0/0",
lightning=None,
)
await start_mint_init(ledger)
yield ledger
@pytest.mark.asyncio
async def test_keysets(ledger: Ledger):
assert len(ledger.keysets.keysets)
assert len(ledger.keysets.get_ids())
assert ledger.keyset.id == "XQM1wwtQbOXE"
@pytest.mark.asyncio
async def test_get_keyset(ledger: Ledger):
keyset = ledger.get_keyset()
assert type(keyset) == dict
assert len(keyset) == MAX_ORDER
@pytest.mark.asyncio
async def test_mint(ledger: Ledger):
blinded_messages_mock = [
BlindedMessage(
amount=8,
B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239",
)
]
promises = await ledger.mint(blinded_messages_mock)
assert len(promises)
assert promises[0].amount == 8
assert (
promises[0].C_
== "032dfadd74bb3abba8170ecbae5401507e384eafd312defda94148fa37314c0ef0"
)
@pytest.mark.asyncio
async def test_mint_invalid_blinded_message(ledger: Ledger):
blinded_messages_mock_invalid_key = [
BlindedMessage(
amount=8,
B_="02634a2c2b34bec9e8a4aba4361f6bff02d7fa2365379b0840afe249a7a9d71237",
)
]
await assert_err(
ledger.mint(blinded_messages_mock_invalid_key), "invalid public key"
)
@pytest.mark.asyncio
async def test_generate_promises(ledger: Ledger):
blinded_messages_mock = [
BlindedMessage(
amount=8,
B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239",
)
]
promises = await ledger._generate_promises(blinded_messages_mock)
assert (
promises[0].C_
== "032dfadd74bb3abba8170ecbae5401507e384eafd312defda94148fa37314c0ef0"
)
@pytest.mark.asyncio
async def test_get_output_split(ledger: Ledger):
assert ledger._get_output_split(13) == [1, 4, 8]

View File

@@ -7,6 +7,7 @@ import pytest_asyncio
from cashu.core.base import Proof
from cashu.core.helpers import async_unwrap, sum_proofs
from cashu.core.migrations import migrate_databases
from cashu.core.settings import MAX_ORDER
from cashu.wallet import migrations
from cashu.wallet.wallet import Wallet
from cashu.wallet.wallet import Wallet as Wallet1
@@ -48,6 +49,23 @@ async def wallet2():
yield wallet2
@pytest.mark.asyncio
async def test_get_keys(wallet1: Wallet):
assert len(wallet1.keys) == MAX_ORDER
keyset = await wallet1._get_keys(wallet1.url)
assert type(keyset.id) == str
assert len(keyset.id) > 0
@pytest.mark.asyncio
async def test_get_keysets(wallet1: Wallet):
keyset = await wallet1._get_keysets(wallet1.url)
assert type(keyset) == dict
assert type(keyset["keysets"]) == list
assert len(keyset["keysets"]) > 0
assert keyset["keysets"][0] == wallet1.keyset_id
@pytest.mark.asyncio
async def test_mint(wallet1: Wallet):
await wallet1.mint(64)
@@ -63,6 +81,8 @@ async def test_split(wallet1: Wallet):
assert [p.amount for p in p1] == [4, 8, 32]
assert sum_proofs(p2) == 20
assert [p.amount for p in p2] == [4, 16]
assert all([p.id == wallet1.keyset_id for p in p1])
assert all([p.id == wallet1.keyset_id for p in p2])
@pytest.mark.asyncio