diff --git a/cashu/core/base.py b/cashu/core/base.py index 99ec2a4..7db5eb2 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -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) diff --git a/cashu/core/crypto.py b/cashu/core/crypto.py index 1563d94..a3e0500 100644 --- a/cashu/core/crypto.py +++ b/cashu/core/crypto.py @@ -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] diff --git a/cashu/core/migrations.py b/cashu/core/migrations.py index cc5041f..2388187 100644 --- a/cashu/core/migrations.py +++ b/cashu/core/migrations.py @@ -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) diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 7c92601..15f9a1d 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -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" diff --git a/cashu/lightning/__init__.py b/cashu/lightning/__init__.py index bc14007..54af20c 100644 --- a/cashu/lightning/__init__.py +++ b/cashu/lightning/__init__.py @@ -1,3 +1,3 @@ -from cashu.lightning.lnbits import LNbitsWallet +# from cashu.lightning.lnbits import LNbitsWallet -WALLET = LNbitsWallet() +# WALLET = LNbitsWallet() diff --git a/cashu/mint/__init__.py b/cashu/mint/__init__.py index 50d1a38..8b13789 100644 --- a/cashu/mint/__init__.py +++ b/cashu/mint/__init__.py @@ -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") diff --git a/cashu/mint/app.py b/cashu/mint/app.py index 002d03e..a0c572e 100644 --- a/cashu/mint/app.py +++ b/cashu/mint/app.py @@ -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() diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 0e6cd38..6ce7120 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -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), diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 83b6b31..0621f92 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -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 diff --git a/cashu/mint/main.py b/cashu/mint/main.py index 50c4a6a..5cce3a1 100644 --- a/cashu/mint/main.py +++ b/cashu/mint/main.py @@ -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: diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index c58b3a0..51a6459 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -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" + ) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 2845850..db18cee 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -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)) diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index f148e2c..a006e07 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -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") diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 02ca6eb..6f17cfa 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -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() diff --git a/poetry.lock b/poetry.lock index c7eb74e..a0dffb8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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"}, diff --git a/pyproject.toml b/pyproject.toml index b8622c3..add76ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.3.3" +version = "0.4.0" description = "Ecash wallet and mint." authors = ["calle "] 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} diff --git a/requirements.txt b/requirements.txt index 0065b77..475f862 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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" diff --git a/setup.py b/setup.py index ac45661..1769ca8 100644 --- a/setup.py +++ b/setup.py @@ -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", diff --git a/tests/test_mint.py b/tests/test_mint.py new file mode 100644 index 0000000..ad5713b --- /dev/null +++ b/tests/test_mint.py @@ -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] diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 087ca9f..08ca8aa 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -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