From 6ddce571a071d81aec6f4e2a196c5ee9452ad394 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 2 Feb 2024 21:25:02 +0100 Subject: [PATCH] Keysets per seed and postgres (#400) * allow generation of keys per seed phrase * emit errors correctly * parse timestamps for melt and mint quotes correctly * error messages * adjust error message * postgres works * prepare postgres tests * timestamps refactor * add command to key activation * generate keys per seed * add keyset tests * keyest uniqueness constaint on (derivation_path, seed) * add tables ony if not exists * log leve --- .github/workflows/ci.yml | 2 +- cashu/core/base.py | 75 +++++++++++++++++++++++++++----- cashu/core/db.py | 23 +++++++++- cashu/core/errors.py | 18 ++++---- cashu/mint/crud.py | 44 +++++++++++-------- cashu/mint/ledger.py | 59 ++++++++++++------------- cashu/mint/migrations.py | 77 ++++++++++++++++++++++++++++----- cashu/mint/router.py | 23 ++++------ cashu/mint/router_deprecated.py | 10 ++--- cashu/mint/startup.py | 4 +- tests/test_mint_db.py | 63 +++++++++++++++++++++++++++ tests/test_mint_keysets.py | 57 ++++++++++++++++++++++++ 12 files changed, 352 insertions(+), 103 deletions(-) create mode 100644 tests/test_mint_db.py create mode 100644 tests/test_mint_keysets.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 873fce8..95be906 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: poetry-version: ["1.7.1"] mint-cache-secrets: ["false", "true"] mint-only-deprecated: ["false", "true"] - # db-url: ["", "postgres://cashu:cashu@localhost:5432/test"] # TODO: Postgres test not working + # db-url: ["", "postgres://cashu:cashu@localhost:5432/cashu"] # TODO: Postgres test not working db-url: [""] backend-wallet-class: ["FakeWallet"] uses: ./.github/workflows/tests.yml diff --git a/cashu/core/base.py b/cashu/core/base.py index c3ab37e..071fbf3 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -218,11 +218,37 @@ class MeltQuote(BaseModel): amount: int fee_reserve: int paid: bool - created_time: int = 0 - paid_time: int = 0 + created_time: Union[int, None] = None + paid_time: Union[int, None] = None fee_paid: int = 0 proof: str = "" + @classmethod + def from_row(cls, row: Row): + try: + created_time = int(row["created_time"]) if row["created_time"] else None + paid_time = int(row["paid_time"]) if row["paid_time"] else None + except Exception: + created_time = ( + int(row["created_time"].timestamp()) if row["created_time"] else None + ) + paid_time = int(row["paid_time"].timestamp()) if row["paid_time"] else None + + return cls( + quote=row["quote"], + method=row["method"], + request=row["request"], + checking_id=row["checking_id"], + unit=row["unit"], + amount=row["amount"], + fee_reserve=row["fee_reserve"], + paid=row["paid"], + created_time=created_time, + paid_time=paid_time, + fee_paid=row["fee_paid"], + proof=row["proof"], + ) + class MintQuote(BaseModel): quote: str @@ -233,10 +259,37 @@ class MintQuote(BaseModel): amount: int paid: bool issued: bool - created_time: int = 0 - paid_time: int = 0 + created_time: Union[int, None] = None + paid_time: Union[int, None] = None expiry: int = 0 + @classmethod + def from_row(cls, row: Row): + + try: + # SQLITE: row is timestamp (string) + created_time = int(row["created_time"]) if row["created_time"] else None + paid_time = int(row["paid_time"]) if row["paid_time"] else None + except Exception: + # POSTGRES: row is datetime.datetime + created_time = ( + int(row["created_time"].timestamp()) if row["created_time"] else None + ) + paid_time = int(row["paid_time"].timestamp()) if row["paid_time"] else None + return cls( + quote=row["quote"], + method=row["method"], + request=row["request"], + checking_id=row["checking_id"], + unit=row["unit"], + amount=row["amount"], + paid=row["paid"], + issued=row["issued"], + created_time=created_time, + paid_time=paid_time, + expiry=0, + ) + # ------- API ------- @@ -640,7 +693,7 @@ class MintKeyset: active: bool unit: Unit derivation_path: str - seed: Optional[str] = None + seed: str public_keys: Union[Dict[int, PublicKey], None] = None valid_from: Union[str, None] = None valid_to: Union[str, None] = None @@ -652,17 +705,17 @@ class MintKeyset: def __init__( self, *, + seed: str, + derivation_path: str, id="", valid_from=None, valid_to=None, first_seen=None, active=None, - seed: Optional[str] = None, - derivation_path: Optional[str] = None, unit: Optional[str] = None, version: str = "0", ): - self.derivation_path = derivation_path or "" + self.derivation_path = derivation_path self.seed = seed self.id = id self.valid_from = valid_from @@ -696,8 +749,10 @@ class MintKeyset: self.unit = Unit[unit] # generate keys from seed - if self.seed and self.derivation_path: - self.generate_keys() + assert self.seed, "seed not set" + assert self.derivation_path, "derivation path not set" + + self.generate_keys() logger.debug(f"Keyset id: {self.id} ({self.unit.name})") diff --git a/cashu/core/db.py b/cashu/core/db.py index 1f96910..1863c60 100644 --- a/cashu/core/db.py +++ b/cashu/core/db.py @@ -31,7 +31,8 @@ class Compat: if self.type in {POSTGRES, COCKROACH}: return "now()" elif self.type == SQLITE: - return "(strftime('%s', 'now'))" + # return "(strftime('%s', 'now'))" + return str(int(time.time())) return "" @property @@ -204,6 +205,26 @@ def lock_table(db: Database, table: str) -> str: return "" +def timestamp_from_seconds( + db: Database, seconds: Union[int, float, None] +) -> Union[str, None]: + if seconds is None: + return None + seconds = int(seconds) + if db.type in {POSTGRES, COCKROACH}: + return datetime.datetime.fromtimestamp(seconds).strftime("%Y-%m-%d %H:%M:%S") + elif db.type == SQLITE: + return str(seconds) + return None + + +def timestamp_now(db: Database) -> str: + timestamp = timestamp_from_seconds(db, time.time()) + if timestamp is None: + raise Exception("Timestamp is None") + return timestamp + + @asynccontextmanager async def get_db_connection(db: Database, conn: Optional[Connection] = None): """Either yield the existing database connection or create a new one. diff --git a/cashu/core/errors.py b/cashu/core/errors.py index fa2ca4a..d36614a 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -12,7 +12,7 @@ class CashuError(Exception): class NotAllowedError(CashuError): - detail = "Not allowed." + detail = "not allowed" code = 10000 def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): @@ -20,7 +20,7 @@ class NotAllowedError(CashuError): class TransactionError(CashuError): - detail = "Transaction error." + detail = "transaction error" code = 11000 def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): @@ -36,7 +36,7 @@ class TokenAlreadySpentError(TransactionError): class SecretTooLongError(TransactionError): - detail = "Secret too long." + detail = "secret too long" code = 11003 def __init__(self): @@ -44,7 +44,7 @@ class SecretTooLongError(TransactionError): class NoSecretInProofsError(TransactionError): - detail = "No secret in proofs." + detail = "no secret in proofs" code = 11004 def __init__(self): @@ -52,7 +52,7 @@ class NoSecretInProofsError(TransactionError): class KeysetError(CashuError): - detail = "Keyset error." + detail = "keyset error" code = 12000 def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): @@ -60,7 +60,7 @@ class KeysetError(CashuError): class KeysetNotFoundError(KeysetError): - detail = "Keyset not found." + detail = "keyset not found" code = 12001 def __init__(self): @@ -68,15 +68,15 @@ class KeysetNotFoundError(KeysetError): class LightningError(CashuError): - detail = "Lightning error." + detail = "Lightning error" code = 20000 def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): super().__init__(detail or self.detail, code=code or self.code) -class InvoiceNotPaidError(CashuError): - detail = "Lightning invoice not paid yet." +class QuoteNotPaidError(CashuError): + detail = "quote not paid" code = 20001 def __init__(self): diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 2d9660a..06ce347 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -1,4 +1,3 @@ -import time from abc import ABC, abstractmethod from typing import Any, List, Optional @@ -9,7 +8,13 @@ from ..core.base import ( MintQuote, Proof, ) -from ..core.db import Connection, Database, table_with_schema +from ..core.db import ( + Connection, + Database, + table_with_schema, + timestamp_from_seconds, + timestamp_now, +) class LedgerCrud(ABC): @@ -27,6 +32,7 @@ class LedgerCrud(ABC): db: Database, id: str = "", derivation_path: str = "", + seed: str = "", conn: Optional[Connection] = None, ) -> List[MintKeyset]: ... @@ -223,7 +229,7 @@ class LedgerCrudSqlite(LedgerCrud): e, s, id, - int(time.time()), + timestamp_now(db), ), ) @@ -274,7 +280,7 @@ class LedgerCrudSqlite(LedgerCrud): proof.secret, proof.id, proof.witness, - int(time.time()), + timestamp_now(db), ), ) @@ -307,7 +313,7 @@ class LedgerCrudSqlite(LedgerCrud): proof.amount, str(proof.C), str(proof.secret), - int(time.time()), + timestamp_now(db), ), ) @@ -348,8 +354,8 @@ class LedgerCrudSqlite(LedgerCrud): quote.amount, quote.issued, quote.paid, - quote.created_time, - quote.paid_time, + timestamp_from_seconds(db, quote.created_time), + timestamp_from_seconds(db, quote.paid_time), ), ) @@ -367,7 +373,7 @@ class LedgerCrudSqlite(LedgerCrud): """, (quote_id,), ) - return MintQuote(**dict(row)) if row else None + return MintQuote.from_row(row) if row else None async def get_mint_quote_by_checking_id( self, @@ -383,7 +389,7 @@ class LedgerCrudSqlite(LedgerCrud): """, (checking_id,), ) - return MintQuote(**dict(row)) if row else None + return MintQuote.from_row(row) if row else None async def update_mint_quote( self, @@ -398,7 +404,7 @@ class LedgerCrudSqlite(LedgerCrud): ( quote.issued, quote.paid, - quote.paid_time, + timestamp_from_seconds(db, quote.paid_time), quote.quote, ), ) @@ -442,8 +448,8 @@ class LedgerCrudSqlite(LedgerCrud): quote.amount, quote.fee_reserve or 0, quote.paid, - quote.created_time, - quote.paid_time, + timestamp_from_seconds(db, quote.created_time), + timestamp_from_seconds(db, quote.paid_time), quote.fee_paid, quote.proof, ), @@ -481,7 +487,7 @@ class LedgerCrudSqlite(LedgerCrud): ) if row is None: return None - return MeltQuote(**dict(row)) if row else None + return MeltQuote.from_row(row) if row else None async def update_melt_quote( self, @@ -496,7 +502,7 @@ class LedgerCrudSqlite(LedgerCrud): ( quote.paid, quote.fee_paid, - quote.paid_time, + timestamp_from_seconds(db, quote.paid_time), quote.proof, quote.quote, ), @@ -519,9 +525,9 @@ class LedgerCrudSqlite(LedgerCrud): keyset.id, keyset.seed, keyset.derivation_path, - keyset.valid_from or int(time.time()), - keyset.valid_to or int(time.time()), - keyset.first_seen or int(time.time()), + keyset.valid_from or timestamp_now(db), + keyset.valid_to or timestamp_now(db), + keyset.first_seen or timestamp_now(db), True, keyset.version, keyset.unit.name, @@ -545,6 +551,7 @@ class LedgerCrudSqlite(LedgerCrud): db: Database, id: Optional[str] = None, derivation_path: Optional[str] = None, + seed: Optional[str] = None, unit: Optional[str] = None, active: Optional[bool] = None, conn: Optional[Connection] = None, @@ -560,6 +567,9 @@ class LedgerCrudSqlite(LedgerCrud): if derivation_path is not None: clauses.append("derivation_path = ?") values.append(derivation_path) + if seed is not None: + clauses.append("seed = ?") + values.append(seed) if unit is not None: clauses.append("unit = ?") values.append(unit) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 7c61c45..8b07d93 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -38,6 +38,7 @@ from ..core.errors import ( KeysetNotFoundError, LightningError, NotAllowedError, + QuoteNotPaidError, TransactionError, ) from ..core.helpers import sum_proofs @@ -81,7 +82,14 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): # ------- KEYS ------- - async def activate_keyset(self, derivation_path, autosave=True) -> MintKeyset: + async def activate_keyset( + self, + *, + derivation_path: str, + seed: Optional[str] = None, + version: Optional[str] = None, + autosave=True, + ) -> MintKeyset: """Load the keyset for a derivation path if it already exists. If not generate new one and store in the db. Args: @@ -91,29 +99,25 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): Returns: MintKeyset: Keyset """ + assert derivation_path, "derivation path not set" + seed = seed or self.master_key logger.debug(f"Activating keyset for derivation path {derivation_path}") # load the keyset from db logger.trace(f"crud: loading keyset for {derivation_path}") tmp_keyset_local: List[MintKeyset] = await self.crud.get_keyset( - derivation_path=derivation_path, db=self.db + derivation_path=derivation_path, seed=seed, db=self.db ) logger.trace(f"crud: loaded {len(tmp_keyset_local)} keysets") if tmp_keyset_local: # we have a keyset with this derivation path in the database keyset = tmp_keyset_local[0] - # we keys are not stored in the database but only their derivation path - # so we might need to generate the keys for keysets loaded from the database - if not len(keyset.private_keys): - keyset.generate_keys() - else: - logger.trace(f"crud: no keyset for {derivation_path}") # no keyset for this derivation path yet # we create a new keyset (keys will be generated at instantiation) keyset = MintKeyset( - seed=self.master_key, + seed=seed or self.master_key, derivation_path=derivation_path, - version=settings.version, + version=version or settings.version, ) logger.debug(f"Generated new keyset {keyset.id}.") if autosave: @@ -144,33 +148,24 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): not in the database yet. Will be passed to `self.activate_keyset` where it is generated from `self.derivation_path`. Defaults to True. """ - # load all past keysets from db + # load all past keysets from db, the keys will be generated at instantiation tmp_keysets: List[MintKeyset] = await self.crud.get_keyset(db=self.db) - logger.debug( - f"Loaded {len(tmp_keysets)} keysets from database. Generating keys..." - ) - # add keysets from db to current keysets + + # add keysets from db to memory for k in tmp_keysets: self.keysets[k.id] = k - # generate keys for all keysets in the database - for _, v in self.keysets.items(): - # if we already generated the keys for this keyset, skip - if v.id and v.public_keys and len(v.public_keys): - continue - logger.trace(f"Generating keys for keyset {v.id}") - v.seed = self.master_key - v.generate_keys() - - logger.info(f"Initialized {len(self.keysets)} keysets from the database.") - # activate the current keyset set by self.derivation_path - self.keyset = await self.activate_keyset(self.derivation_path, autosave) + if self.derivation_path: + self.keyset = await self.activate_keyset( + derivation_path=self.derivation_path, autosave=autosave + ) + logger.info(f"Current keyset: {self.keyset.id}") + logger.info( - "Activated keysets from database:" + f"Loaded {len(self.keysets)} keysets:" f" {[f'{k} ({v.unit.name})' for k, v in self.keysets.items()]}" ) - logger.info(f"Current keyset: {self.keyset.id}") # check that we have a least one active keyset assert any([k.active for k in self.keysets.values()]), "No active keyset found." @@ -189,7 +184,6 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): self.keysets[keyset_copy.id] = keyset_copy # remember which keyset this keyset was duplicated from logger.debug(f"Duplicated keyset id {keyset.id} -> {keyset_copy.id}") - # END BACKWARDS COMPATIBILITY < 0.15.0 def get_keyset(self, keyset_id: Optional[str] = None) -> Dict[int, str]: @@ -295,6 +289,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): MintQuote: Mint quote object. """ logger.trace("called request_mint") + assert quote_request.amount > 0, "amount must be positive" if settings.mint_max_peg_in and quote_request.amount > settings.mint_max_peg_in: raise NotAllowedError( f"Maximum mint amount is {settings.mint_max_peg_in} sat." @@ -368,6 +363,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): if status.paid: logger.trace(f"Setting quote {quote_id} as paid") quote.paid = True + quote.paid_time = int(time.time()) await self.crud.update_mint_quote(quote=quote, db=self.db) return quote @@ -404,7 +400,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): ) # create a new lock if it doesn't exist async with self.locks[quote_id]: quote = await self.get_mint_quote(quote_id=quote_id) - assert quote.paid, "quote not paid" + assert quote.paid, QuoteNotPaidError() assert not quote.issued, "quote already issued" assert ( quote.amount == sum_amount_outputs @@ -593,6 +589,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): await self.crud.update_melt_quote(quote=melt_quote, db=self.db) mint_quote.paid = True + mint_quote.paid_time = melt_quote.paid_time await self.crud.update_mint_quote(quote=mint_quote, db=self.db) return melt_quote diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 7216040..1f360f6 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -1,6 +1,4 @@ -import time - -from ..core.db import Connection, Database, table_with_schema +from ..core.db import SQLITE, Connection, Database, table_with_schema, timestamp_now from ..core.settings import settings @@ -224,18 +222,34 @@ async def m010_add_index_to_proofs_used(db: Database): async def m011_add_quote_tables(db: Database): + async def get_columns(db: Database, conn: Connection, table: str): + if db.type == SQLITE: + query = f"PRAGMA table_info({table})" + else: + query = ( + "SELECT column_name FROM information_schema.columns WHERE table_name =" + f" '{table}'" + ) + res = await conn.execute(query) + if db.type == SQLITE: + return [r["name"] async for r in res] + else: + return [r["column_name"] async for r in res] + async with db.connect() as conn: # add column "created" to tables invoices, promises, proofs_used, proofs_pending tables = ["invoices", "promises", "proofs_used", "proofs_pending"] for table in tables: - await conn.execute( - f"ALTER TABLE {table_with_schema(db, table)} ADD COLUMN created" - " TIMESTAMP" - ) - await conn.execute( - f"UPDATE {table_with_schema(db, table)} SET created =" - f" '{int(time.time())}'" - ) + columns = await get_columns(db, conn, table) + if "created" not in columns: + await conn.execute( + f"ALTER TABLE {table_with_schema(db, table)} ADD COLUMN created" + " TIMESTAMP" + ) + await conn.execute( + f"UPDATE {table_with_schema(db, table)} SET created =" + f" '{timestamp_now(db)}'" + ) # add column "witness" to table proofs_used await conn.execute( @@ -299,8 +313,47 @@ async def m011_add_quote_tables(db: Database): f"INSERT INTO {table_with_schema(db, 'mint_quotes')} (quote, method," " request, checking_id, unit, amount, paid, issued, created_time," " paid_time) SELECT id, 'bolt11', bolt11, payment_hash, 'sat', amount," - f" False, issued, created, 0 FROM {table_with_schema(db, 'invoices')} " + f" False, issued, created, NULL FROM {table_with_schema(db, 'invoices')} " ) # drop table invoices await conn.execute(f"DROP TABLE {table_with_schema(db, 'invoices')}") + + +async def m012_keysets_uniqueness_with_seed(db: Database): + # copy table keysets to keysets_old, create a new table keysets + # with the same columns but with a unique constraint on (seed, derivation_path) + # and copy the data from keysets_old to keysets, then drop keysets_old + async with db.connect() as conn: + await conn.execute( + f"DROP TABLE IF EXISTS {table_with_schema(db, 'keysets_old')}" + ) + await conn.execute( + f"CREATE TABLE {table_with_schema(db, 'keysets_old')} AS" + f" SELECT * FROM {table_with_schema(db, 'keysets')}" + ) + await conn.execute(f"DROP TABLE {table_with_schema(db, 'keysets')}") + await conn.execute(f""" + CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'keysets')} ( + id TEXT NOT NULL, + derivation_path TEXT, + seed TEXT, + 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, + version TEXT, + unit TEXT, + + UNIQUE (seed, derivation_path) + + ); + """) + await conn.execute( + f"INSERT INTO {table_with_schema(db, 'keysets')} (id," + " derivation_path, valid_from, valid_to, first_seen," + " active, version, seed, unit) SELECT id, derivation_path," + " valid_from, valid_to, first_seen, active, version, seed," + f" unit FROM {table_with_schema(db, 'keysets_old')}" + ) + await conn.execute(f"DROP TABLE {table_with_schema(db, 'keysets_old')}") diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 6219b19..02289bc 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List -from fastapi import APIRouter, Request +from fastapi import APIRouter from loguru import logger from ..core.base import ( @@ -80,8 +80,7 @@ async def info() -> GetInfoResponse: name="Mint public keys", summary="Get the public keys of the newest mint keyset", response_description=( - "A dictionary of all supported token values of the mint and their associated" - " public key of the current keyset." + "All supported token values their associated public keys for all active keysets" ), response_model=KeysResponse, ) @@ -107,12 +106,12 @@ async def keys(): name="Keyset public keys", summary="Public keys of a specific keyset", response_description=( - "A dictionary of all supported token values of the mint and their associated" + "All supported token values of the mint and their associated" " public key for a specific keyset." ), response_model=KeysResponse, ) -async def keyset_keys(keyset_id: str, request: Request) -> KeysResponse: +async def keyset_keys(keyset_id: str) -> KeysResponse: """ Get the public keys of the mint from a specific keyset id. """ @@ -127,7 +126,7 @@ async def keyset_keys(keyset_id: str, request: Request) -> KeysResponse: keyset = ledger.keysets.get(keyset_id) if keyset is None: - raise CashuError(code=0, detail="Keyset not found.") + raise CashuError(code=0, detail="keyset not found") keyset_for_response = KeysResponseKeyset( id=keyset.id, @@ -172,12 +171,6 @@ async def mint_quote(payload: PostMintQuoteRequest) -> PostMintQuoteResponse: Call `POST /v1/mint/bolt11` after paying the invoice. """ logger.trace(f"> POST /v1/mint/quote/bolt11: payload={payload}") - amount = payload.amount - if amount > 21_000_000 * 100_000_000 or amount <= 0: - raise CashuError(code=0, detail="Amount must be a valid amount of sat.") - if settings.mint_peg_out_only: - raise CashuError(code=0, detail="Mint does not allow minting new tokens.") - quote = await ledger.mint_quote(payload) resp = PostMintQuoteResponse( request=quote.request, @@ -213,8 +206,8 @@ async def get_mint_quote(quote: str) -> PostMintQuoteResponse: @router.post( "/v1/mint/bolt11", - name="Mint tokens", - summary="Mint tokens in exchange for a Bitcoin payment that the user has made", + name="Mint tokens with a Lightning payment", + summary="Mint tokens by paying a bolt11 Lightning invoice.", response_model=PostMintResponse, response_description=( "A list of blinded signatures that can be used to create proofs." @@ -311,7 +304,7 @@ async def melt(payload: PostMeltRequest) -> PostMeltResponse: "An array of blinded signatures that can be used to create proofs." ), ) -async def split( +async def swap( payload: PostSplitRequest, ) -> PostSplitResponse: """ diff --git a/cashu/mint/router_deprecated.py b/cashu/mint/router_deprecated.py index b1d4be6..a2ac71b 100644 --- a/cashu/mint/router_deprecated.py +++ b/cashu/mint/router_deprecated.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Dict, List, Optional from fastapi import APIRouter from loguru import logger @@ -70,7 +70,7 @@ async def info() -> GetInfoResponse_deprecated: response_model=KeysResponse_deprecated, deprecated=True, ) -async def keys_deprecated(): +async def keys_deprecated() -> Dict[str, str]: """This endpoint returns a dictionary of all supported token values of the mint and their associated public key.""" logger.trace("> GET /keys") keyset = ledger.get_keyset() @@ -86,10 +86,10 @@ async def keys_deprecated(): "A dictionary of all supported token values of the mint and their associated" " public key for a specific keyset." ), - response_model=KeysResponse_deprecated, + response_model=Dict[str, str], deprecated=True, ) -async def keyset_deprecated(idBase64Urlsafe: str): +async def keyset_deprecated(idBase64Urlsafe: str) -> Dict[str, str]: """ Get the public keys of the mint from a specific keyset id. The id is encoded in idBase64Urlsafe (by a wallet) and is converted back to @@ -323,7 +323,7 @@ async def split_deprecated( ), deprecated=True, ) -async def check_spendable( +async def check_spendable_deprecated( payload: CheckSpendableRequest_deprecated, ) -> CheckSpendableResponse_deprecated: """Check whether a secret has been spent already or not.""" diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index acc7855..84e31e8 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -56,7 +56,7 @@ async def rotate_keys(n_seconds=60): incremented_derivation_path = ( "/".join(ledger.derivation_path.split("/")[:-1]) + f"/{i}" ) - await ledger.activate_keyset(incremented_derivation_path) + await ledger.activate_keyset(derivation_path=incremented_derivation_path) logger.info(f"Current keyset: {ledger.keyset.id}") await asyncio.sleep(n_seconds) @@ -68,7 +68,7 @@ async def start_mint_init(): await ledger.init_keysets() for derivation_path in settings.mint_derivation_path_list: - await ledger.activate_keyset(derivation_path) + await ledger.activate_keyset(derivation_path=derivation_path) for method in ledger.backends: for unit in ledger.backends[method]: diff --git a/tests/test_mint_db.py b/tests/test_mint_db.py new file mode 100644 index 0000000..d45a420 --- /dev/null +++ b/tests/test_mint_db.py @@ -0,0 +1,63 @@ +import pytest +import pytest_asyncio + +from cashu.core.base import PostMeltQuoteRequest +from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet +from cashu.wallet.wallet import Wallet as Wallet1 +from tests.conftest import SERVER_ENDPOINT + + +async def assert_err(f, msg): + """Compute f() and expect an error message 'msg'.""" + try: + await f + except Exception as exc: + if msg not in str(exc.args[0]): + raise Exception(f"Expected error: {msg}, got: {exc.args[0]}") + return + raise Exception(f"Expected error: {msg}, got no error") + + +@pytest_asyncio.fixture(scope="function") +async def wallet1(ledger: Ledger): + wallet1 = await Wallet1.with_db( + url=SERVER_ENDPOINT, + db="test_data/wallet1", + name="wallet1", + ) + await wallet1.load_mint() + yield wallet1 + + +@pytest.mark.asyncio +async def test_mint_quote(wallet1: Wallet, ledger: Ledger): + invoice = await wallet1.request_mint(128) + assert invoice is not None + quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db) + assert quote is not None + assert quote.quote == invoice.id + assert quote.amount == 128 + assert quote.unit == "sat" + assert not quote.paid + assert quote.checking_id == invoice.payment_hash + assert quote.paid_time is None + assert quote.created_time + + +@pytest.mark.asyncio +async def test_melt_quote(wallet1: Wallet, ledger: Ledger): + invoice = await wallet1.request_mint(128) + assert invoice is not None + melt_quote = await ledger.melt_quote( + PostMeltQuoteRequest(request=invoice.bolt11, unit="sat") + ) + quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db) + assert quote is not None + assert quote.quote == melt_quote.quote + assert quote.amount == 128 + assert quote.unit == "sat" + assert not quote.paid + assert quote.checking_id == invoice.payment_hash + assert quote.paid_time is None + assert quote.created_time diff --git a/tests/test_mint_keysets.py b/tests/test_mint_keysets.py new file mode 100644 index 0000000..6fad6af --- /dev/null +++ b/tests/test_mint_keysets.py @@ -0,0 +1,57 @@ +import pytest + +from cashu.core.base import MintKeyset +from cashu.core.settings import settings + +SEED = "TEST_PRIVATE_KEY" +DERIVATION_PATH = "m/0'/0'/0'" + + +async def assert_err(f, msg): + """Compute f() and expect an error message 'msg'.""" + try: + await f + except Exception as exc: + if msg not in str(exc.args[0]): + raise Exception(f"Expected error: {msg}, got: {exc.args[0]}") + return + raise Exception(f"Expected error: {msg}, got no error") + + +@pytest.mark.asyncio +async def test_keyset_0_15_0(): + keyset = MintKeyset(seed=SEED, derivation_path=DERIVATION_PATH, version="0.15.0") + assert len(keyset.public_keys_hex) == settings.max_order + assert keyset.seed == "TEST_PRIVATE_KEY" + assert keyset.derivation_path == "m/0'/0'/0'" + assert ( + keyset.public_keys_hex[1] + == "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" + ) + assert keyset.id == "009a1f293253e41e" + + +@pytest.mark.asyncio +async def test_keyset_0_14_0(): + keyset = MintKeyset(seed=SEED, derivation_path=DERIVATION_PATH, version="0.14.0") + assert len(keyset.public_keys_hex) == settings.max_order + assert keyset.seed == "TEST_PRIVATE_KEY" + assert keyset.derivation_path == "m/0'/0'/0'" + assert ( + keyset.public_keys_hex[1] + == "036d6f3adf897e88e16ece3bffb2ce57a0b635fa76f2e46dbe7c636a937cd3c2f2" + ) + assert keyset.id == "xnI+Y0j7cT1/" + + +@pytest.mark.asyncio +async def test_keyset_0_11_0(): + keyset = MintKeyset(seed=SEED, derivation_path=DERIVATION_PATH, version="0.11.0") + assert len(keyset.public_keys_hex) == settings.max_order + assert keyset.seed == "TEST_PRIVATE_KEY" + assert keyset.derivation_path == "m/0'/0'/0'" + assert ( + keyset.public_keys_hex[1] + == "026b714529f157d4c3de5a93e3a67618475711889b6434a497ae6ad8ace6682120" + ) + assert keyset.id == "Zkdws9zWxNc4"