diff --git a/cashu/core/base.py b/cashu/core/base.py index 8922752..1d04709 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -413,6 +413,8 @@ class MintQuote(LedgerEvent): paid_time: Union[int, None] = None expiry: Optional[int] = None mint: Optional[str] = None + privkey: Optional[str] = None + pubkey: Optional[str] = None @classmethod def from_row(cls, row: Row): @@ -436,6 +438,8 @@ class MintQuote(LedgerEvent): state=MintQuoteState(row["state"]), created_time=created_time, paid_time=paid_time, + pubkey=row["pubkey"] if "pubkey" in row.keys() else None, + privkey=row["privkey"] if "privkey" in row.keys() else None, ) @classmethod @@ -458,6 +462,7 @@ class MintQuote(LedgerEvent): mint=mint, expiry=mint_quote_resp.expiry, created_time=int(time.time()), + pubkey=mint_quote_resp.pubkey, ) @property diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 36700ac..13095c6 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -96,3 +96,19 @@ class QuoteNotPaidError(CashuError): def __init__(self): super().__init__(self.detail, code=2001) + + +class QuoteSignatureInvalidError(CashuError): + detail = "Signature for mint request invalid" + code = 20008 + + def __init__(self): + super().__init__(self.detail, code=20008) + + +class QuoteRequiresPubkeyError(CashuError): + detail = "Pubkey required for mint quote" + code = 20009 + + def __init__(self): + super().__init__(self.detail, code=20009) diff --git a/cashu/core/models.py b/cashu/core/models.py index ac11377..e840e4e 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -128,21 +128,25 @@ class PostMintQuoteRequest(BaseModel): description: Optional[str] = Field( default=None, max_length=settings.mint_max_request_length ) # invoice description + pubkey: Optional[str] = Field( + default=None, max_length=settings.mint_max_request_length + ) # NUT-20 quote lock pubkey class PostMintQuoteResponse(BaseModel): quote: str # quote id request: str # input payment request - paid: Optional[bool] # DEPRECATED as per NUT-04 PR #141 - state: Optional[str] # state of the quote + state: Optional[str] # state of the quote (optional for backwards compat) expiry: Optional[int] # expiry of the quote + pubkey: Optional[str] = None # NUT-20 quote lock pubkey + paid: Optional[bool] = None # DEPRECATED as per NUT-04 PR #141 @classmethod - def from_mint_quote(self, mint_quote: MintQuote) -> "PostMintQuoteResponse": + def from_mint_quote(cls, mint_quote: MintQuote) -> "PostMintQuoteResponse": to_dict = mint_quote.dict() # turn state into string to_dict["state"] = mint_quote.state.value - return PostMintQuoteResponse.parse_obj(to_dict) + return cls.parse_obj(to_dict) # ------- API: MINT ------- @@ -153,6 +157,9 @@ class PostMintRequest(BaseModel): outputs: List[BlindedMessage] = Field( ..., max_items=settings.mint_max_request_length ) + signature: Optional[str] = Field( + default=None, max_length=settings.mint_max_request_length + ) # NUT-20 quote signature class PostMintResponse(BaseModel): diff --git a/cashu/core/nuts/__init__.py b/cashu/core/nuts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cashu/core/nuts/nut20.py b/cashu/core/nuts/nut20.py new file mode 100644 index 0000000..591497b --- /dev/null +++ b/cashu/core/nuts/nut20.py @@ -0,0 +1,41 @@ +from hashlib import sha256 +from typing import List + +from ..base import BlindedMessage +from ..crypto.secp import PrivateKey, PublicKey + + +def generate_keypair() -> tuple[str, str]: + privkey = PrivateKey() + assert privkey.pubkey + pubkey = privkey.pubkey + return privkey.serialize(), pubkey.serialize(True).hex() + + +def construct_message(quote_id: str, outputs: List[BlindedMessage]) -> bytes: + serialized_outputs = b"".join([o.B_.encode("utf-8") for o in outputs]) + msgbytes = sha256(quote_id.encode("utf-8") + serialized_outputs).digest() + return msgbytes + + +def sign_mint_quote( + quote_id: str, + outputs: List[BlindedMessage], + private_key: str, +) -> str: + privkey = PrivateKey(bytes.fromhex(private_key), raw=True) + msgbytes = construct_message(quote_id, outputs) + sig = privkey.schnorr_sign(msgbytes, None, raw=True) + return sig.hex() + + +def verify_mint_quote( + quote_id: str, + outputs: List[BlindedMessage], + public_key: str, + signature: str, +) -> bool: + pubkey = PublicKey(bytes.fromhex(public_key), raw=True) + msgbytes = construct_message(quote_id, outputs) + sig = bytes.fromhex(signature) + return pubkey.schnorr_verify(msgbytes, sig, None, raw=True) diff --git a/cashu/core/nuts.py b/cashu/core/nuts/nuts.py similarity index 88% rename from cashu/core/nuts.py rename to cashu/core/nuts/nuts.py index a2cb062..69c78a0 100644 --- a/cashu/core/nuts.py +++ b/cashu/core/nuts/nuts.py @@ -13,3 +13,4 @@ HTLC_NUT = 14 MPP_NUT = 15 WEBSOCKETS_NUT = 17 CACHE_NUT = 19 +MINT_QUOTE_SIGNATURE_NUT = 20 diff --git a/cashu/core/p2pk.py b/cashu/core/p2pk.py index 23f379d..eb07a03 100644 --- a/cashu/core/p2pk.py +++ b/cashu/core/p2pk.py @@ -50,22 +50,3 @@ def verify_schnorr_signature( return pubkey.schnorr_verify( hashlib.sha256(message).digest(), signature, None, raw=True ) - - -if __name__ == "__main__": - # generate keys - private_key_bytes = b"12300000000000000000000000000123" - private_key = PrivateKey(private_key_bytes, raw=True) - print(private_key.serialize()) - public_key = private_key.pubkey - assert public_key - print(public_key.serialize().hex()) - - # sign message (=pubkey) - message = public_key.serialize() - signature = private_key.ecdsa_serialize(private_key.ecdsa_sign(message)) - print(signature.hex()) - - # verify - pubkey_verify = PublicKey(message, raw=True) - print(public_key.ecdsa_verify(message, pubkey_verify.ecdsa_deserialize(signature))) diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 0fc53f8..89feb3c 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -421,8 +421,8 @@ class LedgerCrudSqlite(LedgerCrud): await (conn or db).execute( f""" INSERT INTO {db.table_with_schema('mint_quotes')} - (quote, method, request, checking_id, unit, amount, paid, issued, state, created_time, paid_time) - VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :paid, :issued, :state, :created_time, :paid_time) + (quote, method, request, checking_id, unit, amount, paid, issued, state, created_time, paid_time, pubkey) + VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :paid, :issued, :state, :created_time, :paid_time, :pubkey) """, { "quote": quote.quote, @@ -440,6 +440,7 @@ class LedgerCrudSqlite(LedgerCrud): "paid_time": db.to_timestamp( db.timestamp_from_seconds(quote.paid_time) or "" ), + "pubkey": quote.pubkey or "" }, ) diff --git a/cashu/mint/features.py b/cashu/mint/features.py index 42d9276..fb9c319 100644 --- a/cashu/mint/features.py +++ b/cashu/mint/features.py @@ -5,13 +5,14 @@ from ..core.models import ( MeltMethodSetting, MintMethodSetting, ) -from ..core.nuts import ( +from ..core.nuts.nuts import ( CACHE_NUT, DLEQ_NUT, FEE_RETURN_NUT, HTLC_NUT, MELT_NUT, MINT_NUT, + MINT_QUOTE_SIGNATURE_NUT, MPP_NUT, P2PK_NUT, RESTORE_NUT, @@ -75,6 +76,7 @@ class LedgerFeatures(SupportsBackends): mint_features[P2PK_NUT] = supported_dict mint_features[DLEQ_NUT] = supported_dict mint_features[HTLC_NUT] = supported_dict + mint_features[MINT_QUOTE_SIGNATURE_NUT] = supported_dict return mint_features def add_mpp_features( diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 9161092..4bac51e 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -38,6 +38,7 @@ from ..core.errors import ( LightningError, NotAllowedError, QuoteNotPaidError, + QuoteSignatureInvalidError, TransactionError, ) from ..core.helpers import sum_proofs @@ -459,6 +460,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe state=MintQuoteState.unpaid, created_time=int(time.time()), expiry=expiry, + pubkey=quote_request.pubkey, ) await self.crud.store_mint_quote(quote=quote, db=self.db) await self.events.submit(quote) @@ -518,13 +520,14 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe *, outputs: List[BlindedMessage], quote_id: str, + signature: Optional[str] = None, ) -> List[BlindedSignature]: """Mints new coins if quote with `quote_id` was paid. Ingest blind messages `outputs` and returns blind signatures `promises`. Args: outputs (List[BlindedMessage]): Outputs (blinded messages) to sign. quote_id (str): Mint quote id. - keyset (Optional[MintKeyset], optional): Keyset to use. If not provided, uses active keyset. Defaults to None. + witness (Optional[str], optional): NUT-19 witness signature. Defaults to None. Raises: Exception: Validation of outputs failed. @@ -536,7 +539,6 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe Returns: List[BlindedSignature]: Signatures on the outputs. """ - await self._verify_outputs(outputs) sum_amount_outputs = sum([b.amount for b in outputs]) # we already know from _verify_outputs that all outputs have the same unit because they have the same keyset @@ -549,6 +551,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe raise TransactionError("Mint quote already issued.") if not quote.paid: raise QuoteNotPaidError() + previous_state = quote.state await self.db_write._set_mint_quote_pending(quote_id=quote_id) try: @@ -558,6 +561,9 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe raise TransactionError("amount to mint does not match quote amount") if quote.expiry and quote.expiry > int(time.time()): raise TransactionError("quote expired") + if not self._verify_mint_quote_witness(quote, outputs, signature): + raise QuoteSignatureInvalidError() + promises = await self._generate_promises(outputs) except Exception as e: await self.db_write._unset_mint_quote_pending( diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index e4a559c..39c2948 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -838,3 +838,12 @@ async def m022_quote_set_states_to_values(db: Database): await conn.execute( f"UPDATE {db.table_with_schema('mint_quotes')} SET state = '{mint_quote_states.value}' WHERE state = '{mint_quote_states.name}'" ) + +async def m023_add_key_to_mint_quote_table(db: Database): + async with db.connect() as conn: + await conn.execute( + f""" + ALTER TABLE {db.table_with_schema('mint_quotes')} + ADD COLUMN pubkey TEXT DEFAULT NULL + """ + ) \ No newline at end of file diff --git a/cashu/mint/router.py b/cashu/mint/router.py index e66530d..d1f514d 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -174,6 +174,7 @@ async def mint_quote( paid=quote.paid, # deprecated state=quote.state.value, expiry=quote.expiry, + pubkey=quote.pubkey, ) logger.trace(f"< POST /v1/mint/quote/bolt11: {resp}") return resp @@ -198,6 +199,7 @@ async def get_mint_quote(request: Request, quote: str) -> PostMintQuoteResponse: paid=mint_quote.paid, # deprecated state=mint_quote.state.value, expiry=mint_quote.expiry, + pubkey=mint_quote.pubkey, ) logger.trace(f"< GET /v1/mint/quote/bolt11/{quote}") return resp @@ -251,7 +253,9 @@ async def mint( """ logger.trace(f"> POST /v1/mint/bolt11: {payload}") - promises = await ledger.mint(outputs=payload.outputs, quote_id=payload.quote) + promises = await ledger.mint( + outputs=payload.outputs, quote_id=payload.quote, signature=payload.signature + ) blinded_signatures = PostMintResponse(signatures=promises) logger.trace(f"< POST /v1/mint/bolt11: {blinded_signatures}") return blinded_signatures diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index ca34b7b..377779e 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -7,6 +7,7 @@ from ..core.base import ( BlindedSignature, Method, MintKeyset, + MintQuote, Proof, Unit, ) @@ -20,6 +21,7 @@ from ..core.errors import ( TransactionError, TransactionUnitError, ) +from ..core.nuts import nut20 from ..core.settings import settings from ..lightning.base import LightningBackend from ..mint.crud import LedgerCrud @@ -277,3 +279,16 @@ class LedgerVerification( ) return unit, method + + def _verify_mint_quote_witness( + self, + quote: MintQuote, + outputs: List[BlindedMessage], + signature: Optional[str], + ) -> bool: + """Verify signature on quote id and outputs""" + if not quote.pubkey: + return True + if not signature: + return False + return nut20.verify_mint_quote(quote.quote, outputs, quote.pubkey, signature) diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index fef2d45..824ec80 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -348,7 +348,9 @@ async def invoice( try: asyncio.run( wallet.mint( - int(amount), split=optional_split, quote_id=mint_quote.quote + int(amount), + split=optional_split, + quote_id=mint_quote.quote, ) ) # set paid so we won't react to any more callbacks @@ -402,7 +404,9 @@ async def invoice( mint_quote_resp = await wallet.get_mint_quote(mint_quote.quote) if mint_quote_resp.state == MintQuoteState.paid.value: await wallet.mint( - amount, split=optional_split, quote_id=mint_quote.quote + amount, + split=optional_split, + quote_id=mint_quote.quote, ) paid = True else: @@ -423,7 +427,14 @@ async def invoice( # user paid invoice before and wants to check the quote id elif amount and id: - await wallet.mint(amount, split=optional_split, quote_id=id) + quote = await get_bolt11_mint_quote(wallet.db, quote=id) + if not quote: + raise Exception("Quote not found") + await wallet.mint( + amount, + split=optional_split, + quote_id=quote.quote, + ) # close open subscriptions so we can exit try: @@ -921,11 +932,13 @@ async def invoices(ctx, paid: bool, unpaid: bool, pending: bool, mint: bool): print("No invoices found.") return - async def _try_to_mint_pending_invoice(amount: int, id: str) -> Optional[MintQuote]: + async def _try_to_mint_pending_invoice( + amount: int, quote_id: str + ) -> Optional[MintQuote]: try: - proofs = await wallet.mint(amount, id) + proofs = await wallet.mint(amount, quote_id) print(f"Received {wallet.unit.str(sum_proofs(proofs))}") - return await get_bolt11_mint_quote(db=wallet.db, quote=id) + return await get_bolt11_mint_quote(db=wallet.db, quote=quote_id) except Exception as e: logger.error(f"Could not mint pending invoice: {e}") return None diff --git a/cashu/wallet/compat.py b/cashu/wallet/compat.py new file mode 100644 index 0000000..00590ad --- /dev/null +++ b/cashu/wallet/compat.py @@ -0,0 +1,49 @@ +import base64 + +from loguru import logger + +from ..core.crypto.keys import derive_keyset_id +from ..core.db import Database +from ..core.settings import settings +from .crud import ( + get_keysets, + update_keyset, +) +from .protocols import SupportsDb, SupportsMintURL + + +class WalletCompat(SupportsDb, SupportsMintURL): + db: Database + + async def inactivate_base64_keysets(self, force_old_keysets: bool) -> None: + # BEGIN backwards compatibility: phase out keysets with base64 ID by treating them as inactive + if settings.wallet_inactivate_base64_keysets and not force_old_keysets: + keysets_in_db = await get_keysets(mint_url=self.url, db=self.db) + for keyset in keysets_in_db: + if not keyset.active: + continue + # test if the keyset id is a hex string, if not it's base64 + try: + int(keyset.id, 16) + except ValueError: + # verify that it's base64 + try: + _ = base64.b64decode(keyset.id) + except ValueError: + logger.error("Unexpected: keyset id is neither hex nor base64.") + continue + + # verify that we have a hex version of the same keyset by comparing public keys + hex_keyset_id = derive_keyset_id(keys=keyset.public_keys) + if hex_keyset_id not in [k.id for k in keysets_in_db]: + logger.warning( + f"Keyset {keyset.id} is base64 but we don't have a hex version. Ignoring." + ) + continue + + logger.warning( + f"Keyset {keyset.id} is base64 and has a hex counterpart, setting inactive." + ) + keyset.active = False + await update_keyset(keyset=keyset, db=self.db) + # END backwards compatibility diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index 1cf62a2..75b42d3 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -253,8 +253,8 @@ async def store_bolt11_mint_quote( await (conn or db).execute( """ INSERT INTO bolt11_mint_quotes - (quote, mint, method, request, checking_id, unit, amount, state, created_time, paid_time, expiry) - VALUES (:quote, :mint, :method, :request, :checking_id, :unit, :amount, :state, :created_time, :paid_time, :expiry) + (quote, mint, method, request, checking_id, unit, amount, state, created_time, paid_time, expiry, privkey) + VALUES (:quote, :mint, :method, :request, :checking_id, :unit, :amount, :state, :created_time, :paid_time, :expiry, :privkey) """, { "quote": quote.quote, @@ -268,6 +268,7 @@ async def store_bolt11_mint_quote( "created_time": quote.created_time, "paid_time": quote.paid_time, "expiry": quote.expiry, + "privkey": quote.privkey or "", }, ) diff --git a/cashu/wallet/lightning/lightning.py b/cashu/wallet/lightning/lightning.py index 8f630b9..176b6e0 100644 --- a/cashu/wallet/lightning/lightning.py +++ b/cashu/wallet/lightning/lightning.py @@ -107,7 +107,10 @@ class LightningWallet(Wallet): return PaymentStatus(result=PaymentResult.SETTLED) try: # to check the invoice state, we try minting tokens - await self.mint(mint_quote.amount, quote_id=mint_quote.quote) + await self.mint( + mint_quote.amount, + quote_id=mint_quote.quote, + ) return PaymentStatus(result=PaymentResult.SETTLED) except Exception as e: print(e) diff --git a/cashu/wallet/migrations.py b/cashu/wallet/migrations.py index 8587caf..af9eff1 100644 --- a/cashu/wallet/migrations.py +++ b/cashu/wallet/migrations.py @@ -286,3 +286,12 @@ async def m013_add_mint_and_melt_quote_tables(db: Database): ); """ ) + +async def m013_add_key_to_mint_quote_table(db: Database): + async with db.connect() as conn: + await conn.execute( + """ + ALTER TABLE bolt11_mint_quotes + ADD COLUMN privkey TEXT DEFAULT NULL; + """ + ) \ No newline at end of file diff --git a/cashu/wallet/mint_info.py b/cashu/wallet/mint_info.py index a9154c8..0064981 100644 --- a/cashu/wallet/mint_info.py +++ b/cashu/wallet/mint_info.py @@ -4,7 +4,7 @@ from pydantic import BaseModel from ..core.base import Method, Unit from ..core.models import MintInfoContact, Nut15MppSupport -from ..core.nuts import MPP_NUT, WEBSOCKETS_NUT +from ..core.nuts.nuts import MINT_QUOTE_SIGNATURE_NUT, MPP_NUT, WEBSOCKETS_NUT class MintInfo(BaseModel): @@ -53,3 +53,11 @@ class MintInfo(BaseModel): if "bolt11_mint_quote" in entry["commands"]: return True return False + + def supports_mint_quote_signature(self) -> bool: + if not self.nuts: + return False + nut20 = self.nuts.get(MINT_QUOTE_SIGNATURE_NUT, None) + if nut20: + return nut20["supported"] + return False diff --git a/cashu/wallet/protocols.py b/cashu/wallet/protocols.py index 1f381a1..8b4a2ce 100644 --- a/cashu/wallet/protocols.py +++ b/cashu/wallet/protocols.py @@ -1,8 +1,8 @@ -from typing import Dict, Protocol +from typing import Dict, List, Protocol import httpx -from ..core.base import Unit, WalletKeyset +from ..core.base import Proof, Unit, WalletKeyset from ..core.crypto.secp import PrivateKey from ..core.db import Database @@ -13,6 +13,7 @@ class SupportsPrivateKey(Protocol): class SupportsDb(Protocol): db: Database + proofs: List[Proof] class SupportsKeysets(Protocol): diff --git a/cashu/wallet/secrets.py b/cashu/wallet/secrets.py index ef35f02..6bfc812 100644 --- a/cashu/wallet/secrets.py +++ b/cashu/wallet/secrets.py @@ -156,26 +156,30 @@ class WalletSecrets(SupportsDb, SupportsKeysets): """ if n < 1: return [], [], [] + async with self.db.get_connection(lock_table="keysets") as conn: + secret_counters_start = await bump_secret_derivation( + db=self.db, keyset_id=self.keyset_id, by=n, skip=skip_bump, conn=conn + ) + logger.trace(f"secret_counters_start: {secret_counters_start}") + secret_counters = list( + range(secret_counters_start, secret_counters_start + n) + ) + logger.trace( + f"Generating secret nr {secret_counters[0]} to {secret_counters[-1]}." + ) + secrets_rs_derivationpaths = [ + await self.generate_determinstic_secret(s) for s in secret_counters + ] + # secrets are supplied as str + secrets = [s[0].hex() for s in secrets_rs_derivationpaths] + # rs are supplied as PrivateKey + rs = [ + PrivateKey(privkey=s[1], raw=True) for s in secrets_rs_derivationpaths + ] - secret_counters_start = await bump_secret_derivation( - db=self.db, keyset_id=self.keyset_id, by=n, skip=skip_bump - ) - logger.trace(f"secret_counters_start: {secret_counters_start}") - secret_counters = list(range(secret_counters_start, secret_counters_start + n)) - logger.trace( - f"Generating secret nr {secret_counters[0]} to {secret_counters[-1]}." - ) - secrets_rs_derivationpaths = [ - await self.generate_determinstic_secret(s) for s in secret_counters - ] - # secrets are supplied as str - secrets = [s[0].hex() for s in secrets_rs_derivationpaths] - # rs are supplied as PrivateKey - rs = [PrivateKey(privkey=s[1], raw=True) for s in secrets_rs_derivationpaths] + derivation_paths = [s[2] for s in secrets_rs_derivationpaths] - derivation_paths = [s[2] for s in secrets_rs_derivationpaths] - - return secrets, rs, derivation_paths + return secrets, rs, derivation_paths async def generate_secrets_from_to( self, from_counter: int, to_counter: int, keyset_id: Optional[str] = None diff --git a/cashu/wallet/transactions.py b/cashu/wallet/transactions.py index 51b0343..0fb83d6 100644 --- a/cashu/wallet/transactions.py +++ b/cashu/wallet/transactions.py @@ -1,5 +1,5 @@ import uuid -from typing import Dict, List, Union +from typing import Dict, List, Optional, Tuple, Union from loguru import logger @@ -10,6 +10,8 @@ from ..core.base import ( ) from ..core.db import Database from ..core.helpers import amount_summary, sum_proofs +from ..core.settings import settings +from ..core.split import amount_split from ..wallet.crud import ( update_proof, ) @@ -109,6 +111,102 @@ class WalletTransactions(SupportsDb, SupportsKeysets): proofs_send = self.coinselect(proofs, amount, include_fees=True) return self.get_fees_for_proofs(proofs_send) + def split_wallet_state(self, amount: int) -> List[int]: + """This function produces an amount split for outputs based on the current state of the wallet. + Its objective is to fill up the wallet so that it reaches `n_target` coins of each amount. + + Args: + amount (int): Amount to split + + Returns: + List[int]: List of amounts to mint + """ + # read the target count for each amount from settings + n_target = settings.wallet_target_amount_count + amounts_we_have = [p.amount for p in self.proofs if p.reserved is not True] + amounts_we_have.sort() + # NOTE: Do not assume 2^n here + all_possible_amounts: list[int] = [2**i for i in range(settings.max_order)] + amounts_we_want_ll = [ + [a] * max(0, n_target - amounts_we_have.count(a)) + for a in all_possible_amounts + ] + # flatten list of lists to list + amounts_we_want = [item for sublist in amounts_we_want_ll for item in sublist] + # sort by increasing amount + amounts_we_want.sort() + + logger.trace( + f"Amounts we have: {[(a, amounts_we_have.count(a)) for a in set(amounts_we_have)]}" + ) + amounts: list[int] = [] + while sum(amounts) < amount and amounts_we_want: + if sum(amounts) + amounts_we_want[0] > amount: + break + amounts.append(amounts_we_want.pop(0)) + + remaining_amount = amount - sum(amounts) + if remaining_amount > 0: + amounts += amount_split(remaining_amount) + amounts.sort() + + logger.trace(f"Amounts we want: {amounts}") + if sum(amounts) != amount: + raise Exception(f"Amounts do not sum to {amount}.") + + return amounts + + def determine_output_amounts( + self, + proofs: List[Proof], + amount: int, + include_fees: bool = False, + keyset_id_outputs: Optional[str] = None, + ) -> Tuple[List[int], List[int]]: + """This function generates a suitable amount split for the outputs to keep and the outputs to send. It + calculates the amount to keep based on the wallet state and the amount to send based on the amount + provided. + + Amount to keep is based on the proofs we have in the wallet + Amount to send is optimally split based on the amount provided plus optionally the fees required to receive them. + + Args: + proofs (List[Proof]): Proofs to be split. + amount (int): Amount to be sent. + include_fees (bool, optional): If True, the fees are included in the amount to send (output of + this method, to be sent in the future). This is not the fee that is required to swap the + `proofs` (input to this method). Defaults to False. + keyset_id_outputs (str, optional): The keyset ID of the outputs to be produced, used to determine the + fee if `include_fees` is set. + + Returns: + Tuple[List[int], List[int]]: Two lists of amounts, one for keeping and one for sending. + """ + # create a suitable amount split based on the proofs provided + total = sum_proofs(proofs) + keep_amt, send_amt = total - amount, amount + + if include_fees: + keyset_id = keyset_id_outputs or self.keyset_id + tmp_proofs = [Proof(id=keyset_id) for _ in amount_split(send_amt)] + fee = self.get_fees_for_proofs(tmp_proofs) + keep_amt -= fee + send_amt += fee + + logger.trace(f"Keep amount: {keep_amt}, send amount: {send_amt}") + logger.trace(f"Total input: {sum_proofs(proofs)}") + # generate optimal split for outputs to send + send_amounts = amount_split(send_amt) + + # we subtract the input fee for the entire transaction from the amount to keep + keep_amt -= self.get_fees_for_proofs(proofs) + logger.trace(f"Keep amount: {keep_amt}") + + # we determine the amounts to keep based on the wallet state + keep_amounts = self.split_wallet_state(keep_amt) + + return keep_amounts, send_amounts + async def set_reserved(self, proofs: List[Proof], reserved: bool) -> None: """Mark a proof as reserved or reset it in the wallet db to avoid reuse when it is sent. diff --git a/cashu/wallet/v1_api.py b/cashu/wallet/v1_api.py index 26bff61..4f4fe61 100644 --- a/cashu/wallet/v1_api.py +++ b/cashu/wallet/v1_api.py @@ -282,7 +282,11 @@ class LedgerAPI(LedgerAPIDeprecated): @async_set_httpx_client @async_ensure_mint_loaded async def mint_quote( - self, amount: int, unit: Unit, memo: Optional[str] = None + self, + amount: int, + unit: Unit, + memo: Optional[str] = None, + pubkey: Optional[str] = None, ) -> PostMintQuoteResponse: """Requests a mint quote from the server and returns a payment request. @@ -290,7 +294,7 @@ class LedgerAPI(LedgerAPIDeprecated): amount (int): Amount of tokens to mint unit (Unit): Unit of the amount memo (Optional[str], optional): Memo to attach to Lightning invoice. Defaults to None. - + pubkey (Optional[str], optional): Public key from which to expect a signature in a subsequent mint request. Returns: PostMintQuoteResponse: Mint Quote Response @@ -298,7 +302,9 @@ class LedgerAPI(LedgerAPIDeprecated): Exception: If the mint request fails """ logger.trace("Requesting mint: POST /v1/mint/bolt11") - payload = PostMintQuoteRequest(unit=unit.name, amount=amount, description=memo) + payload = PostMintQuoteRequest( + unit=unit.name, amount=amount, description=memo, pubkey=pubkey + ) resp = await self.httpx.post( join(self.url, "/v1/mint/quote/bolt11"), json=payload.dict() ) @@ -333,13 +339,14 @@ class LedgerAPI(LedgerAPIDeprecated): @async_set_httpx_client @async_ensure_mint_loaded async def mint( - self, outputs: List[BlindedMessage], quote: str + self, outputs: List[BlindedMessage], quote: str, signature: Optional[str] = None ) -> List[BlindedSignature]: """Mints new coins and returns a proof of promise. Args: outputs (List[BlindedMessage]): Outputs to mint new tokens with quote (str): Quote ID. + signature (Optional[str], optional): NUT-19 signature of the request. Returns: list[Proof]: List of proofs. @@ -347,16 +354,21 @@ class LedgerAPI(LedgerAPIDeprecated): Raises: Exception: If the minting fails """ - outputs_payload = PostMintRequest(outputs=outputs, quote=quote) + outputs_payload = PostMintRequest( + outputs=outputs, quote=quote, signature=signature + ) logger.trace("Checking Lightning invoice. POST /v1/mint/bolt11") def _mintrequest_include_fields(outputs: List[BlindedMessage]): """strips away fields from the model that aren't necessary for the /mint""" outputs_include = {"id", "amount", "B_"} - return { + res = { "quote": ..., "outputs": {i: outputs_include for i in range(len(outputs))}, } + if signature: + res["signature"] = ... + return res payload = outputs_payload.dict(include=_mintrequest_include_fields(outputs)) # type: ignore resp = await self.httpx.post( diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index f0a9d76..ba2020f 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -1,4 +1,3 @@ -import base64 import copy import threading import time @@ -20,7 +19,6 @@ from ..core.base import ( WalletKeyset, ) from ..core.crypto import b_dhke -from ..core.crypto.keys import derive_keyset_id from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Database from ..core.errors import KeysetNotFoundError @@ -36,12 +34,14 @@ from ..core.models import ( PostCheckStateResponse, PostMeltQuoteResponse, ) +from ..core.nuts import nut20 from ..core.p2pk import Secret from ..core.settings import settings -from ..core.split import amount_split from . import migrations +from .compat import WalletCompat from .crud import ( bump_secret_derivation, + get_bolt11_mint_quote, get_keysets, get_proofs, invalidate_proof, @@ -68,7 +68,13 @@ from .v1_api import LedgerAPI class Wallet( - LedgerAPI, WalletP2PK, WalletHTLC, WalletSecrets, WalletTransactions, WalletProofs + LedgerAPI, + WalletP2PK, + WalletHTLC, + WalletSecrets, + WalletTransactions, + WalletProofs, + WalletCompat, ): """ Nutshell wallet class. @@ -248,39 +254,6 @@ class Wallet( await self.load_keysets_from_db() - async def inactivate_base64_keysets(self, force_old_keysets: bool) -> None: - # BEGIN backwards compatibility: phase out keysets with base64 ID by treating them as inactive - if settings.wallet_inactivate_base64_keysets and not force_old_keysets: - keysets_in_db = await get_keysets(mint_url=self.url, db=self.db) - for keyset in keysets_in_db: - if not keyset.active: - continue - # test if the keyset id is a hex string, if not it's base64 - try: - int(keyset.id, 16) - except ValueError: - # verify that it's base64 - try: - _ = base64.b64decode(keyset.id) - except ValueError: - logger.error("Unexpected: keyset id is neither hex nor base64.") - continue - - # verify that we have a hex version of the same keyset by comparing public keys - hex_keyset_id = derive_keyset_id(keys=keyset.public_keys) - if hex_keyset_id not in [k.id for k in keysets_in_db]: - logger.warning( - f"Keyset {keyset.id} is base64 but we don't have a hex version. Ignoring." - ) - continue - - logger.warning( - f"Keyset {keyset.id} is base64 and has a hex counterpart, setting inactive." - ) - keyset.active = False - await update_keyset(keyset=keyset, db=self.db) - # END backwards compatibility - async def activate_keyset(self, keyset_id: Optional[str] = None) -> None: """Activates a keyset by setting self.keyset_id. Either activates a specific keyset of chooses one of the active keysets of the mint with the same unit as the wallet. @@ -386,7 +359,10 @@ class Wallet( logger.trace("Secret check complete.") async def request_mint_with_callback( - self, amount: int, callback: Callable, memo: Optional[str] = None + self, + amount: int, + callback: Callable, + memo: Optional[str] = None, ) -> Tuple[MintQuote, SubscriptionManager]: """Request a quote invoice for minting tokens. @@ -398,84 +374,56 @@ class Wallet( Returns: MintQuote: Mint Quote """ - mint_qoute = await super().mint_quote(amount, self.unit, memo) + # generate a key for signing the quote request + privkey_hex, pubkey_hex = nut20.generate_keypair() + mint_quote = await super().mint_quote(amount, self.unit, memo, pubkey_hex) subscriptions = SubscriptionManager(self.url) threading.Thread( target=subscriptions.connect, name="SubscriptionManager", daemon=True ).start() subscriptions.subscribe( kind=JSONRPCSubscriptionKinds.BOLT11_MINT_QUOTE, - filters=[mint_qoute.quote], + filters=[mint_quote.quote], callback=callback, ) - quote = MintQuote.from_resp_wallet(mint_qoute, self.url, amount, self.unit.name) + quote = MintQuote.from_resp_wallet(mint_quote, self.url, amount, self.unit.name) + + # store the private key in the quote + quote.privkey = privkey_hex await store_bolt11_mint_quote(db=self.db, quote=quote) return quote, subscriptions - async def request_mint(self, amount: int, memo: Optional[str] = None) -> MintQuote: + async def request_mint( + self, + amount: int, + memo: Optional[str] = None, + ) -> MintQuote: """Request a quote invoice for minting tokens. Args: amount (int): Amount for Lightning invoice in satoshis callback (Optional[Callable], optional): Callback function to be called when the invoice is paid. Defaults to None. memo (Optional[str], optional): Memo for the Lightning invoice. Defaults to None. + keypair (Optional[Tuple[str, str], optional]): NUT-19 private public ephemeral keypair. Defaults to None. Returns: MintQuote: Mint Quote """ - mint_quote_response = await super().mint_quote(amount, self.unit, memo) + # generate a key for signing the quote request + privkey_hex, pubkey_hex = nut20.generate_keypair() + + mint_quote_response = await super().mint_quote( + amount, self.unit, memo, pubkey_hex + ) quote = MintQuote.from_resp_wallet( mint_quote_response, self.url, amount, self.unit.name ) + + quote.privkey = privkey_hex await store_bolt11_mint_quote(db=self.db, quote=quote) return quote - def split_wallet_state(self, amount: int) -> List[int]: - """This function produces an amount split for outputs based on the current state of the wallet. - Its objective is to fill up the wallet so that it reaches `n_target` coins of each amount. - - Args: - amount (int): Amount to split - - Returns: - List[int]: List of amounts to mint - """ - # read the target count for each amount from settings - n_target = settings.wallet_target_amount_count - amounts_we_have = [p.amount for p in self.proofs if p.reserved is not True] - amounts_we_have.sort() - # NOTE: Do not assume 2^n here - all_possible_amounts: list[int] = [2**i for i in range(settings.max_order)] - amounts_we_want_ll = [ - [a] * max(0, n_target - amounts_we_have.count(a)) - for a in all_possible_amounts - ] - # flatten list of lists to list - amounts_we_want = [item for sublist in amounts_we_want_ll for item in sublist] - # sort by increasing amount - amounts_we_want.sort() - - logger.trace( - f"Amounts we have: {[(a, amounts_we_have.count(a)) for a in set(amounts_we_have)]}" - ) - amounts: list[int] = [] - while sum(amounts) < amount and amounts_we_want: - if sum(amounts) + amounts_we_want[0] > amount: - break - amounts.append(amounts_we_want.pop(0)) - - remaining_amount = amount - sum(amounts) - if remaining_amount > 0: - amounts += amount_split(remaining_amount) - amounts.sort() - - logger.trace(f"Amounts we want: {amounts}") - if sum(amounts) != amount: - raise Exception(f"Amounts do not sum to {amount}.") - - return amounts - async def mint( self, amount: int, @@ -509,8 +457,6 @@ class Wallet( # split based on our wallet state amounts = split or self.split_wallet_state(amount) - # if no split was specified, we use the canonical split - # amounts = split or amount_split(amount) # quirk: we skip bumping the secret counter in the database since we are # not sure if the minting will succeed. If it succeeds, we will bump it @@ -521,8 +467,15 @@ class Wallet( await self._check_used_secrets(secrets) outputs, rs = self._construct_outputs(amounts, secrets, rs) + quote = await get_bolt11_mint_quote(db=self.db, quote=quote_id) + if not quote: + raise Exception("Quote not found.") + signature: str | None = None + if quote.privkey: + signature = nut20.sign_mint_quote(quote_id, outputs, quote.privkey) + # will raise exception if mint is unsuccessful - promises = await super().mint(outputs, quote_id) + promises = await super().mint(outputs, quote_id, signature) promises_keyset_id = promises[0].id await bump_secret_derivation( @@ -547,10 +500,7 @@ class Wallet( self, proofs: List[Proof], ) -> Tuple[List[Proof], List[Proof]]: - """Redeem proofs by sending them to yourself (by calling a split).) - Calls `add_witnesses_to_proofs` which parses all proofs and checks whether their - secrets corresponds to any locks that we have the unlock conditions for. If so, - it adds the unlock conditions to the proofs. + """Redeem proofs by sending them to yourself by calling a split. Args: proofs (List[Proof]): Proofs to be redeemed. """ @@ -558,57 +508,6 @@ class Wallet( self.verify_proofs_dleq(proofs) return await self.split(proofs=proofs, amount=0) - def determine_output_amounts( - self, - proofs: List[Proof], - amount: int, - include_fees: bool = False, - keyset_id_outputs: Optional[str] = None, - ) -> Tuple[List[int], List[int]]: - """This function generates a suitable amount split for the outputs to keep and the outputs to send. It - calculates the amount to keep based on the wallet state and the amount to send based on the amount - provided. - - Amount to keep is based on the proofs we have in the wallet - Amount to send is optimally split based on the amount provided plus optionally the fees required to receive them. - - Args: - proofs (List[Proof]): Proofs to be split. - amount (int): Amount to be sent. - include_fees (bool, optional): If True, the fees are included in the amount to send (output of - this method, to be sent in the future). This is not the fee that is required to swap the - `proofs` (input to this method). Defaults to False. - keyset_id_outputs (str, optional): The keyset ID of the outputs to be produced, used to determine the - fee if `include_fees` is set. - - Returns: - Tuple[List[int], List[int]]: Two lists of amounts, one for keeping and one for sending. - """ - # create a suitable amount split based on the proofs provided - total = sum_proofs(proofs) - keep_amt, send_amt = total - amount, amount - - if include_fees: - keyset_id = keyset_id_outputs or self.keyset_id - tmp_proofs = [Proof(id=keyset_id) for _ in amount_split(send_amt)] - fee = self.get_fees_for_proofs(tmp_proofs) - keep_amt -= fee - send_amt += fee - - logger.trace(f"Keep amount: {keep_amt}, send amount: {send_amt}") - logger.trace(f"Total input: {sum_proofs(proofs)}") - # generate optimal split for outputs to send - send_amounts = amount_split(send_amt) - - # we subtract the input fee for the entire transaction from the amount to keep - keep_amt -= self.get_fees_for_proofs(proofs) - logger.trace(f"Keep amount: {keep_amt}") - - # we determine the amounts to keep based on the wallet state - keep_amounts = self.split_wallet_state(keep_amt) - - return keep_amounts, send_amounts - async def split( self, proofs: List[Proof], @@ -622,6 +521,10 @@ class Wallet( and the promises to send (send_outputs). If secret_lock is provided, the wallet will create blinded secrets with those to attach a predefined spending condition to the tokens they want to send. + Calls `add_witnesses_to_proofs` which parses all proofs and checks whether their + secrets corresponds to any locks that we have the unlock conditions for. If so, + it adds the unlock conditions to the proofs. + Args: proofs (List[Proof]): Proofs to be split. amount (int): Amount to be sent. diff --git a/cashu/wallet/wallet_deprecated.py b/cashu/wallet/wallet_deprecated.py index 5f55544..a612cad 100644 --- a/cashu/wallet/wallet_deprecated.py +++ b/cashu/wallet/wallet_deprecated.py @@ -261,6 +261,7 @@ class LedgerAPIDeprecated(SupportsHttpxClient, SupportsMintURL): paid=False, state=MintQuoteState.unpaid.value, expiry=decoded_invoice.date + (decoded_invoice.expiry or 0), + pubkey=None ) @async_set_httpx_client diff --git a/tests/test_mint_api.py b/tests/test_mint_api.py index 4d9f058..284fc45 100644 --- a/tests/test_mint_api.py +++ b/tests/test_mint_api.py @@ -14,7 +14,8 @@ from cashu.core.models import ( PostRestoreRequest, PostRestoreResponse, ) -from cashu.core.nuts import MINT_NUT +from cashu.core.nuts import nut20 +from cashu.core.nuts.nuts import MINT_NUT from cashu.core.settings import settings from cashu.mint.ledger import Ledger from cashu.wallet.crud import bump_secret_derivation @@ -189,12 +190,13 @@ async def test_split(ledger: Ledger, wallet: Wallet): async def test_mint_quote(ledger: Ledger): response = httpx.post( f"{BASE_URL}/v1/mint/quote/bolt11", - json={"unit": "sat", "amount": 100}, + json={"unit": "sat", "amount": 100, "pubkey": "02" + "00" * 32}, ) assert response.status_code == 200, f"{response.url} {response.status_code}" result = response.json() assert result["quote"] assert result["request"] + assert result["pubkey"] == "02" + "00" * 32 # deserialize the response resp_quote = PostMintQuoteResponse(**result) @@ -232,6 +234,7 @@ async def test_mint_quote(ledger: Ledger): # check if DEPRECATED paid flag is also returned assert result2["paid"] is True assert resp_quote.paid is True + assert resp_quote.pubkey == "02" + "00" * 32 @pytest.mark.asyncio @@ -244,10 +247,16 @@ async def test_mint(ledger: Ledger, wallet: Wallet): await pay_if_regtest(mint_quote.request) secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) + assert mint_quote.privkey + signature = nut20.sign_mint_quote(mint_quote.quote, outputs, mint_quote.privkey) outputs_payload = [o.dict() for o in outputs] response = httpx.post( f"{BASE_URL}/v1/mint/bolt11", - json={"quote": mint_quote.quote, "outputs": outputs_payload}, + json={ + "quote": mint_quote.quote, + "outputs": outputs_payload, + "signature": signature, + }, timeout=None, ) assert response.status_code == 200, f"{response.url} {response.status_code}" @@ -261,6 +270,44 @@ async def test_mint(ledger: Ledger, wallet: Wallet): assert "s" in result["signatures"][0]["dleq"] +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_bolt11_no_signature(ledger: Ledger, wallet: Wallet): + """ + For backwards compatibility, we do not require a NUT-20 signature + for minting with bolt11. + """ + + response = httpx.post( + f"{BASE_URL}/v1/mint/quote/bolt11", + json={ + "unit": "sat", + "amount": 64, + # no pubkey + }, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert result["pubkey"] is None + await pay_if_regtest(result["request"]) + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) + outputs_payload = [o.dict() for o in outputs] + response = httpx.post( + f"{BASE_URL}/v1/mint/bolt11", + json={ + "quote": result["quote"], + "outputs": outputs_payload, + # no signature + }, + timeout=None, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + + @pytest.mark.asyncio @pytest.mark.skipif( settings.debug_mint_only_deprecated, diff --git a/tests/test_mint_api_cache.py b/tests/test_mint_api_cache.py index 9e519fe..5776fed 100644 --- a/tests/test_mint_api_cache.py +++ b/tests/test_mint_api_cache.py @@ -2,6 +2,7 @@ import httpx import pytest import pytest_asyncio +from cashu.core.nuts import nut20 from cashu.core.settings import settings from cashu.mint.ledger import Ledger from cashu.wallet.wallet import Wallet @@ -29,21 +30,23 @@ async def wallet(ledger: Ledger): ) async def test_api_mint_cached_responses(wallet: Wallet): # Testing mint - invoice = await wallet.request_mint(64) - await pay_if_regtest(invoice.request) + mint_quote = await wallet.request_mint(64) + await pay_if_regtest(mint_quote.request) - quote_id = invoice.quote + quote_id = mint_quote.quote secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10010, 10011) outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) + assert mint_quote.privkey + signature = nut20.sign_mint_quote(quote_id, outputs, mint_quote.privkey) outputs_payload = [o.dict() for o in outputs] response = httpx.post( f"{BASE_URL}/v1/mint/bolt11", - json={"quote": quote_id, "outputs": outputs_payload}, + json={"quote": quote_id, "outputs": outputs_payload, "signature": signature}, timeout=None, ) response1 = httpx.post( f"{BASE_URL}/v1/mint/bolt11", - json={"quote": quote_id, "outputs": outputs_payload}, + json={"quote": quote_id, "outputs": outputs_payload, "signature": signature}, timeout=None, ) assert response.status_code == 200, f"{response.status_code = }" @@ -57,17 +60,23 @@ async def test_api_mint_cached_responses(wallet: Wallet): reason="settings.mint_redis_cache_enabled is False", ) async def test_api_swap_cached_responses(wallet: Wallet): - quote = await wallet.request_mint(64) - await pay_if_regtest(quote.request) + mint_quote = await wallet.request_mint(64) + await pay_if_regtest(mint_quote.request) - minted = await wallet.mint(64, quote.quote) + minted = await wallet.mint(64, mint_quote.quote) secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10010, 10011) outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) + assert mint_quote.privkey + signature = nut20.sign_mint_quote(mint_quote.quote, outputs, mint_quote.privkey) inputs_payload = [i.dict() for i in minted] outputs_payload = [o.dict() for o in outputs] response = httpx.post( f"{BASE_URL}/v1/swap", - json={"inputs": inputs_payload, "outputs": outputs_payload}, + json={ + "inputs": inputs_payload, + "outputs": outputs_payload, + "signature": signature, + }, timeout=None, ) response1 = httpx.post( @@ -86,15 +95,14 @@ async def test_api_swap_cached_responses(wallet: Wallet): reason="settings.mint_redis_cache_enabled is False", ) async def test_api_melt_cached_responses(wallet: Wallet): - quote = await wallet.request_mint(64) + mint_quote = await wallet.request_mint(64) melt_quote = await wallet.melt_quote(invoice_32sat) - await pay_if_regtest(quote.request) - minted = await wallet.mint(64, quote.quote) + await pay_if_regtest(mint_quote.request) + minted = await wallet.mint(64, mint_quote.quote) secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10010, 10010) outputs, rs = wallet._construct_outputs([32], secrets, rs) - inputs_payload = [i.dict() for i in minted] outputs_payload = [o.dict() for o in outputs] response = httpx.post( diff --git a/tests/test_mint_api_deprecated.py b/tests/test_mint_api_deprecated.py index bf829e7..5426450 100644 --- a/tests/test_mint_api_deprecated.py +++ b/tests/test_mint_api_deprecated.py @@ -6,6 +6,7 @@ from cashu.core.base import Proof, Unit from cashu.core.models import ( CheckSpendableRequest_deprecated, CheckSpendableResponse_deprecated, + GetMintResponse_deprecated, PostRestoreRequest, PostRestoreResponse, ) @@ -124,15 +125,20 @@ async def test_api_mint_validation(ledger): @pytest.mark.asyncio async def test_mint(ledger: Ledger, wallet: Wallet): - mint_quote = await wallet.request_mint(64) - await pay_if_regtest(mint_quote.request) + quote_response = httpx.get( + f"{BASE_URL}/mint", + params={"amount": 64}, + timeout=None, + ) + mint_quote = GetMintResponse_deprecated.parse_obj(quote_response.json()) + await pay_if_regtest(mint_quote.pr) secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) outputs_payload = [o.dict() for o in outputs] response = httpx.post( f"{BASE_URL}/mint", json={"outputs": outputs_payload}, - params={"hash": mint_quote.quote}, + params={"hash": mint_quote.hash}, timeout=None, ) assert response.status_code == 200, f"{response.url} {response.status_code}" diff --git a/tests/test_mint_operations.py b/tests/test_mint_operations.py index 6859e76..ac353ae 100644 --- a/tests/test_mint_operations.py +++ b/tests/test_mint_operations.py @@ -4,6 +4,7 @@ import pytest_asyncio from cashu.core.base import MeltQuoteState from cashu.core.helpers import sum_proofs from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest +from cashu.core.nuts import nut20 from cashu.core.settings import settings from cashu.mint.ledger import Ledger from cashu.wallet.wallet import Wallet @@ -119,9 +120,9 @@ async def test_melt_external(wallet1: Wallet, ledger: Ledger): @pytest.mark.asyncio @pytest.mark.skipif(is_regtest, reason="only works with FakeWallet") async def test_mint_internal(wallet1: Wallet, ledger: Ledger): - mint_quote = await wallet1.request_mint(128) - await ledger.get_mint_quote(mint_quote.quote) - mint_quote = await ledger.get_mint_quote(mint_quote.quote) + wallet_mint_quote = await wallet1.request_mint(128) + await ledger.get_mint_quote(wallet_mint_quote.quote) + mint_quote = await ledger.get_mint_quote(wallet_mint_quote.quote) assert mint_quote.paid, "mint quote should be paid" @@ -136,7 +137,11 @@ async def test_mint_internal(wallet1: Wallet, ledger: Ledger): len(output_amounts) ) outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs) - await ledger.mint(outputs=outputs, quote_id=mint_quote.quote) + assert wallet_mint_quote.privkey + signature = nut20.sign_mint_quote( + mint_quote.quote, outputs, wallet_mint_quote.privkey + ) + await ledger.mint(outputs=outputs, quote_id=mint_quote.quote, signature=signature) await assert_err( ledger.mint(outputs=outputs, quote_id=mint_quote.quote), @@ -151,9 +156,7 @@ async def test_mint_internal(wallet1: Wallet, ledger: Ledger): @pytest.mark.asyncio @pytest.mark.skipif(is_fake, reason="only works with Regtest") async def test_mint_external(wallet1: Wallet, ledger: Ledger): - quote = await ledger.mint_quote(PostMintQuoteRequest(amount=128, unit="sat")) - assert not quote.paid, "mint quote should not be paid" - assert quote.unpaid + quote = await wallet1.request_mint(128) mint_quote = await ledger.get_mint_quote(quote.quote) assert not mint_quote.paid, "mint quote already paid" @@ -179,7 +182,9 @@ async def test_mint_external(wallet1: Wallet, ledger: Ledger): len(output_amounts) ) outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs) - await ledger.mint(outputs=outputs, quote_id=quote.quote) + assert quote.privkey + signature = nut20.sign_mint_quote(quote.quote, outputs, quote.privkey) + await ledger.mint(outputs=outputs, quote_id=quote.quote, signature=signature) mint_quote_after_payment = await ledger.get_mint_quote(quote.quote) assert mint_quote_after_payment.issued, "mint quote should be issued" @@ -311,14 +316,18 @@ async def test_mint_with_same_outputs_twice(wallet1: Wallet, ledger: Ledger): len(output_amounts) ) outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs) - await ledger.mint(outputs=outputs, quote_id=mint_quote.quote) + assert mint_quote.privkey + signature = nut20.sign_mint_quote(mint_quote.quote, outputs, mint_quote.privkey) + await ledger.mint(outputs=outputs, quote_id=mint_quote.quote, signature=signature) # now try to mint with the same outputs again mint_quote_2 = await wallet1.request_mint(128) await pay_if_regtest(mint_quote_2.request) + assert mint_quote_2.privkey + signature = nut20.sign_mint_quote(mint_quote_2.quote, outputs, mint_quote_2.privkey) await assert_err( - ledger.mint(outputs=outputs, quote_id=mint_quote_2.quote), + ledger.mint(outputs=outputs, quote_id=mint_quote_2.quote, signature=signature), "outputs have already been signed before.", ) @@ -338,7 +347,9 @@ async def test_melt_with_same_outputs_twice(wallet1: Wallet, ledger: Ledger): # we use the outputs once for minting mint_quote_2 = await wallet1.request_mint(128) await pay_if_regtest(mint_quote_2.request) - await ledger.mint(outputs=outputs, quote_id=mint_quote_2.quote) + assert mint_quote_2.privkey + signature = nut20.sign_mint_quote(mint_quote_2.quote, outputs, mint_quote_2.privkey) + await ledger.mint(outputs=outputs, quote_id=mint_quote_2.quote, signature=signature) # use the same outputs for melting mint_quote = await ledger.mint_quote(PostMintQuoteRequest(unit="sat", amount=128)) diff --git a/tests/test_wallet_subscription.py b/tests/test_wallet_subscription.py index 94c2652..2c8dd6d 100644 --- a/tests/test_wallet_subscription.py +++ b/tests/test_wallet_subscription.py @@ -5,7 +5,7 @@ import pytest_asyncio from cashu.core.base import Method, MintQuoteState, ProofState from cashu.core.json_rpc.base import JSONRPCNotficationParams -from cashu.core.nuts import WEBSOCKETS_NUT +from cashu.core.nuts.nuts import WEBSOCKETS_NUT from cashu.core.settings import settings from cashu.wallet.wallet import Wallet from tests.conftest import SERVER_ENDPOINT