Support NUT-XX (signatures on quotes) for mint and wallet side (#670)

* nut-19 sign mint quote

* ephemeral key for quote

* `mint` adjustments + crypto/nut19.py

* wip: mint side working

* fix import

* post-merge fixups

* more fixes

* make format

* move nut19 to nuts directory

* `key` -> `privkey` and `pubkey`

* make format

* mint_info method for nut-19 support

* fix tests imports

* fix signature missing positional argument + fix db migration format not correctly escaped + pass in NUT-19 keypair to `request_mint` `request_mint_with_callback`

* make format

* fix `get_invoice_status`

* rename to xx

* nutxx -> nut20

* mypy

* remove `mint_quote_signature_required` as per spec

* wip edits

* clean up

* fix tests

* fix deprecated api tests

* fix redis tests

* fix cache tests

* fix regtest mint external

* fix mint regtest

* add test without signature

* test pubkeys in quotes

* wip

* add compat

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
lollerfirst
2024-12-15 00:39:53 +01:00
committed by GitHub
parent 399c201552
commit d98d166df1
30 changed files with 505 additions and 243 deletions

View File

@@ -413,6 +413,8 @@ class MintQuote(LedgerEvent):
paid_time: Union[int, None] = None paid_time: Union[int, None] = None
expiry: Optional[int] = None expiry: Optional[int] = None
mint: Optional[str] = None mint: Optional[str] = None
privkey: Optional[str] = None
pubkey: Optional[str] = None
@classmethod @classmethod
def from_row(cls, row: Row): def from_row(cls, row: Row):
@@ -436,6 +438,8 @@ class MintQuote(LedgerEvent):
state=MintQuoteState(row["state"]), state=MintQuoteState(row["state"]),
created_time=created_time, created_time=created_time,
paid_time=paid_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 @classmethod
@@ -458,6 +462,7 @@ class MintQuote(LedgerEvent):
mint=mint, mint=mint,
expiry=mint_quote_resp.expiry, expiry=mint_quote_resp.expiry,
created_time=int(time.time()), created_time=int(time.time()),
pubkey=mint_quote_resp.pubkey,
) )
@property @property

View File

@@ -96,3 +96,19 @@ class QuoteNotPaidError(CashuError):
def __init__(self): def __init__(self):
super().__init__(self.detail, code=2001) 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)

View File

@@ -128,21 +128,25 @@ class PostMintQuoteRequest(BaseModel):
description: Optional[str] = Field( description: Optional[str] = Field(
default=None, max_length=settings.mint_max_request_length default=None, max_length=settings.mint_max_request_length
) # invoice description ) # invoice description
pubkey: Optional[str] = Field(
default=None, max_length=settings.mint_max_request_length
) # NUT-20 quote lock pubkey
class PostMintQuoteResponse(BaseModel): class PostMintQuoteResponse(BaseModel):
quote: str # quote id quote: str # quote id
request: str # input payment request request: str # input payment request
paid: Optional[bool] # DEPRECATED as per NUT-04 PR #141 state: Optional[str] # state of the quote (optional for backwards compat)
state: Optional[str] # state of the quote
expiry: Optional[int] # expiry of the quote 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 @classmethod
def from_mint_quote(self, mint_quote: MintQuote) -> "PostMintQuoteResponse": def from_mint_quote(cls, mint_quote: MintQuote) -> "PostMintQuoteResponse":
to_dict = mint_quote.dict() to_dict = mint_quote.dict()
# turn state into string # turn state into string
to_dict["state"] = mint_quote.state.value to_dict["state"] = mint_quote.state.value
return PostMintQuoteResponse.parse_obj(to_dict) return cls.parse_obj(to_dict)
# ------- API: MINT ------- # ------- API: MINT -------
@@ -153,6 +157,9 @@ class PostMintRequest(BaseModel):
outputs: List[BlindedMessage] = Field( outputs: List[BlindedMessage] = Field(
..., max_items=settings.mint_max_request_length ..., 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): class PostMintResponse(BaseModel):

View File

41
cashu/core/nuts/nut20.py Normal file
View File

@@ -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)

View File

@@ -13,3 +13,4 @@ HTLC_NUT = 14
MPP_NUT = 15 MPP_NUT = 15
WEBSOCKETS_NUT = 17 WEBSOCKETS_NUT = 17
CACHE_NUT = 19 CACHE_NUT = 19
MINT_QUOTE_SIGNATURE_NUT = 20

View File

@@ -50,22 +50,3 @@ def verify_schnorr_signature(
return pubkey.schnorr_verify( return pubkey.schnorr_verify(
hashlib.sha256(message).digest(), signature, None, raw=True 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)))

View File

@@ -421,8 +421,8 @@ class LedgerCrudSqlite(LedgerCrud):
await (conn or db).execute( await (conn or db).execute(
f""" f"""
INSERT INTO {db.table_with_schema('mint_quotes')} INSERT INTO {db.table_with_schema('mint_quotes')}
(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) VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :paid, :issued, :state, :created_time, :paid_time, :pubkey)
""", """,
{ {
"quote": quote.quote, "quote": quote.quote,
@@ -440,6 +440,7 @@ class LedgerCrudSqlite(LedgerCrud):
"paid_time": db.to_timestamp( "paid_time": db.to_timestamp(
db.timestamp_from_seconds(quote.paid_time) or "" db.timestamp_from_seconds(quote.paid_time) or ""
), ),
"pubkey": quote.pubkey or ""
}, },
) )

View File

@@ -5,13 +5,14 @@ from ..core.models import (
MeltMethodSetting, MeltMethodSetting,
MintMethodSetting, MintMethodSetting,
) )
from ..core.nuts import ( from ..core.nuts.nuts import (
CACHE_NUT, CACHE_NUT,
DLEQ_NUT, DLEQ_NUT,
FEE_RETURN_NUT, FEE_RETURN_NUT,
HTLC_NUT, HTLC_NUT,
MELT_NUT, MELT_NUT,
MINT_NUT, MINT_NUT,
MINT_QUOTE_SIGNATURE_NUT,
MPP_NUT, MPP_NUT,
P2PK_NUT, P2PK_NUT,
RESTORE_NUT, RESTORE_NUT,
@@ -75,6 +76,7 @@ class LedgerFeatures(SupportsBackends):
mint_features[P2PK_NUT] = supported_dict mint_features[P2PK_NUT] = supported_dict
mint_features[DLEQ_NUT] = supported_dict mint_features[DLEQ_NUT] = supported_dict
mint_features[HTLC_NUT] = supported_dict mint_features[HTLC_NUT] = supported_dict
mint_features[MINT_QUOTE_SIGNATURE_NUT] = supported_dict
return mint_features return mint_features
def add_mpp_features( def add_mpp_features(

View File

@@ -38,6 +38,7 @@ from ..core.errors import (
LightningError, LightningError,
NotAllowedError, NotAllowedError,
QuoteNotPaidError, QuoteNotPaidError,
QuoteSignatureInvalidError,
TransactionError, TransactionError,
) )
from ..core.helpers import sum_proofs from ..core.helpers import sum_proofs
@@ -459,6 +460,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
state=MintQuoteState.unpaid, state=MintQuoteState.unpaid,
created_time=int(time.time()), created_time=int(time.time()),
expiry=expiry, expiry=expiry,
pubkey=quote_request.pubkey,
) )
await self.crud.store_mint_quote(quote=quote, db=self.db) await self.crud.store_mint_quote(quote=quote, db=self.db)
await self.events.submit(quote) await self.events.submit(quote)
@@ -518,13 +520,14 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
*, *,
outputs: List[BlindedMessage], outputs: List[BlindedMessage],
quote_id: str, quote_id: str,
signature: Optional[str] = None,
) -> List[BlindedSignature]: ) -> List[BlindedSignature]:
"""Mints new coins if quote with `quote_id` was paid. Ingest blind messages `outputs` and returns blind signatures `promises`. """Mints new coins if quote with `quote_id` was paid. Ingest blind messages `outputs` and returns blind signatures `promises`.
Args: Args:
outputs (List[BlindedMessage]): Outputs (blinded messages) to sign. outputs (List[BlindedMessage]): Outputs (blinded messages) to sign.
quote_id (str): Mint quote id. 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: Raises:
Exception: Validation of outputs failed. Exception: Validation of outputs failed.
@@ -536,7 +539,6 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
Returns: Returns:
List[BlindedSignature]: Signatures on the outputs. List[BlindedSignature]: Signatures on the outputs.
""" """
await self._verify_outputs(outputs) await self._verify_outputs(outputs)
sum_amount_outputs = sum([b.amount for b in 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 # 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.") raise TransactionError("Mint quote already issued.")
if not quote.paid: if not quote.paid:
raise QuoteNotPaidError() raise QuoteNotPaidError()
previous_state = quote.state previous_state = quote.state
await self.db_write._set_mint_quote_pending(quote_id=quote_id) await self.db_write._set_mint_quote_pending(quote_id=quote_id)
try: try:
@@ -558,6 +561,9 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
raise TransactionError("amount to mint does not match quote amount") raise TransactionError("amount to mint does not match quote amount")
if quote.expiry and quote.expiry > int(time.time()): if quote.expiry and quote.expiry > int(time.time()):
raise TransactionError("quote expired") raise TransactionError("quote expired")
if not self._verify_mint_quote_witness(quote, outputs, signature):
raise QuoteSignatureInvalidError()
promises = await self._generate_promises(outputs) promises = await self._generate_promises(outputs)
except Exception as e: except Exception as e:
await self.db_write._unset_mint_quote_pending( await self.db_write._unset_mint_quote_pending(

View File

@@ -838,3 +838,12 @@ async def m022_quote_set_states_to_values(db: Database):
await conn.execute( await conn.execute(
f"UPDATE {db.table_with_schema('mint_quotes')} SET state = '{mint_quote_states.value}' WHERE state = '{mint_quote_states.name}'" 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
"""
)

View File

@@ -174,6 +174,7 @@ async def mint_quote(
paid=quote.paid, # deprecated paid=quote.paid, # deprecated
state=quote.state.value, state=quote.state.value,
expiry=quote.expiry, expiry=quote.expiry,
pubkey=quote.pubkey,
) )
logger.trace(f"< POST /v1/mint/quote/bolt11: {resp}") logger.trace(f"< POST /v1/mint/quote/bolt11: {resp}")
return resp return resp
@@ -198,6 +199,7 @@ async def get_mint_quote(request: Request, quote: str) -> PostMintQuoteResponse:
paid=mint_quote.paid, # deprecated paid=mint_quote.paid, # deprecated
state=mint_quote.state.value, state=mint_quote.state.value,
expiry=mint_quote.expiry, expiry=mint_quote.expiry,
pubkey=mint_quote.pubkey,
) )
logger.trace(f"< GET /v1/mint/quote/bolt11/{quote}") logger.trace(f"< GET /v1/mint/quote/bolt11/{quote}")
return resp return resp
@@ -251,7 +253,9 @@ async def mint(
""" """
logger.trace(f"> POST /v1/mint/bolt11: {payload}") 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) blinded_signatures = PostMintResponse(signatures=promises)
logger.trace(f"< POST /v1/mint/bolt11: {blinded_signatures}") logger.trace(f"< POST /v1/mint/bolt11: {blinded_signatures}")
return blinded_signatures return blinded_signatures

View File

@@ -7,6 +7,7 @@ from ..core.base import (
BlindedSignature, BlindedSignature,
Method, Method,
MintKeyset, MintKeyset,
MintQuote,
Proof, Proof,
Unit, Unit,
) )
@@ -20,6 +21,7 @@ from ..core.errors import (
TransactionError, TransactionError,
TransactionUnitError, TransactionUnitError,
) )
from ..core.nuts import nut20
from ..core.settings import settings from ..core.settings import settings
from ..lightning.base import LightningBackend from ..lightning.base import LightningBackend
from ..mint.crud import LedgerCrud from ..mint.crud import LedgerCrud
@@ -277,3 +279,16 @@ class LedgerVerification(
) )
return unit, method 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)

View File

@@ -348,7 +348,9 @@ async def invoice(
try: try:
asyncio.run( asyncio.run(
wallet.mint( 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 # 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) mint_quote_resp = await wallet.get_mint_quote(mint_quote.quote)
if mint_quote_resp.state == MintQuoteState.paid.value: if mint_quote_resp.state == MintQuoteState.paid.value:
await wallet.mint( await wallet.mint(
amount, split=optional_split, quote_id=mint_quote.quote amount,
split=optional_split,
quote_id=mint_quote.quote,
) )
paid = True paid = True
else: else:
@@ -423,7 +427,14 @@ async def invoice(
# user paid invoice before and wants to check the quote id # user paid invoice before and wants to check the quote id
elif amount and 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 # close open subscriptions so we can exit
try: try:
@@ -921,11 +932,13 @@ async def invoices(ctx, paid: bool, unpaid: bool, pending: bool, mint: bool):
print("No invoices found.") print("No invoices found.")
return 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: try:
proofs = await wallet.mint(amount, id) proofs = await wallet.mint(amount, quote_id)
print(f"Received {wallet.unit.str(sum_proofs(proofs))}") 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: except Exception as e:
logger.error(f"Could not mint pending invoice: {e}") logger.error(f"Could not mint pending invoice: {e}")
return None return None

49
cashu/wallet/compat.py Normal file
View File

@@ -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

View File

@@ -253,8 +253,8 @@ async def store_bolt11_mint_quote(
await (conn or db).execute( await (conn or db).execute(
""" """
INSERT INTO bolt11_mint_quotes INSERT INTO bolt11_mint_quotes
(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) VALUES (:quote, :mint, :method, :request, :checking_id, :unit, :amount, :state, :created_time, :paid_time, :expiry, :privkey)
""", """,
{ {
"quote": quote.quote, "quote": quote.quote,
@@ -268,6 +268,7 @@ async def store_bolt11_mint_quote(
"created_time": quote.created_time, "created_time": quote.created_time,
"paid_time": quote.paid_time, "paid_time": quote.paid_time,
"expiry": quote.expiry, "expiry": quote.expiry,
"privkey": quote.privkey or "",
}, },
) )

View File

@@ -107,7 +107,10 @@ class LightningWallet(Wallet):
return PaymentStatus(result=PaymentResult.SETTLED) return PaymentStatus(result=PaymentResult.SETTLED)
try: try:
# to check the invoice state, we try minting tokens # 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) return PaymentStatus(result=PaymentResult.SETTLED)
except Exception as e: except Exception as e:
print(e) print(e)

View File

@@ -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;
"""
)

View File

@@ -4,7 +4,7 @@ from pydantic import BaseModel
from ..core.base import Method, Unit from ..core.base import Method, Unit
from ..core.models import MintInfoContact, Nut15MppSupport 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): class MintInfo(BaseModel):
@@ -53,3 +53,11 @@ class MintInfo(BaseModel):
if "bolt11_mint_quote" in entry["commands"]: if "bolt11_mint_quote" in entry["commands"]:
return True return True
return False 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

View File

@@ -1,8 +1,8 @@
from typing import Dict, Protocol from typing import Dict, List, Protocol
import httpx import httpx
from ..core.base import Unit, WalletKeyset from ..core.base import Proof, Unit, WalletKeyset
from ..core.crypto.secp import PrivateKey from ..core.crypto.secp import PrivateKey
from ..core.db import Database from ..core.db import Database
@@ -13,6 +13,7 @@ class SupportsPrivateKey(Protocol):
class SupportsDb(Protocol): class SupportsDb(Protocol):
db: Database db: Database
proofs: List[Proof]
class SupportsKeysets(Protocol): class SupportsKeysets(Protocol):

View File

@@ -156,12 +156,14 @@ class WalletSecrets(SupportsDb, SupportsKeysets):
""" """
if n < 1: if n < 1:
return [], [], [] return [], [], []
async with self.db.get_connection(lock_table="keysets") as conn:
secret_counters_start = await bump_secret_derivation( secret_counters_start = await bump_secret_derivation(
db=self.db, keyset_id=self.keyset_id, by=n, skip=skip_bump db=self.db, keyset_id=self.keyset_id, by=n, skip=skip_bump, conn=conn
) )
logger.trace(f"secret_counters_start: {secret_counters_start}") logger.trace(f"secret_counters_start: {secret_counters_start}")
secret_counters = list(range(secret_counters_start, secret_counters_start + n)) secret_counters = list(
range(secret_counters_start, secret_counters_start + n)
)
logger.trace( logger.trace(
f"Generating secret nr {secret_counters[0]} to {secret_counters[-1]}." f"Generating secret nr {secret_counters[0]} to {secret_counters[-1]}."
) )
@@ -171,7 +173,9 @@ class WalletSecrets(SupportsDb, SupportsKeysets):
# secrets are supplied as str # secrets are supplied as str
secrets = [s[0].hex() for s in secrets_rs_derivationpaths] secrets = [s[0].hex() for s in secrets_rs_derivationpaths]
# rs are supplied as PrivateKey # rs are supplied as PrivateKey
rs = [PrivateKey(privkey=s[1], raw=True) for s in secrets_rs_derivationpaths] 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]

View File

@@ -1,5 +1,5 @@
import uuid import uuid
from typing import Dict, List, Union from typing import Dict, List, Optional, Tuple, Union
from loguru import logger from loguru import logger
@@ -10,6 +10,8 @@ from ..core.base import (
) )
from ..core.db import Database from ..core.db import Database
from ..core.helpers import amount_summary, sum_proofs from ..core.helpers import amount_summary, sum_proofs
from ..core.settings import settings
from ..core.split import amount_split
from ..wallet.crud import ( from ..wallet.crud import (
update_proof, update_proof,
) )
@@ -109,6 +111,102 @@ class WalletTransactions(SupportsDb, SupportsKeysets):
proofs_send = self.coinselect(proofs, amount, include_fees=True) proofs_send = self.coinselect(proofs, amount, include_fees=True)
return self.get_fees_for_proofs(proofs_send) 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: 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. """Mark a proof as reserved or reset it in the wallet db to avoid reuse when it is sent.

View File

@@ -282,7 +282,11 @@ class LedgerAPI(LedgerAPIDeprecated):
@async_set_httpx_client @async_set_httpx_client
@async_ensure_mint_loaded @async_ensure_mint_loaded
async def mint_quote( 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: ) -> PostMintQuoteResponse:
"""Requests a mint quote from the server and returns a payment request. """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 amount (int): Amount of tokens to mint
unit (Unit): Unit of the amount unit (Unit): Unit of the amount
memo (Optional[str], optional): Memo to attach to Lightning invoice. Defaults to None. 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: Returns:
PostMintQuoteResponse: Mint Quote Response PostMintQuoteResponse: Mint Quote Response
@@ -298,7 +302,9 @@ class LedgerAPI(LedgerAPIDeprecated):
Exception: If the mint request fails Exception: If the mint request fails
""" """
logger.trace("Requesting mint: POST /v1/mint/bolt11") 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( resp = await self.httpx.post(
join(self.url, "/v1/mint/quote/bolt11"), json=payload.dict() join(self.url, "/v1/mint/quote/bolt11"), json=payload.dict()
) )
@@ -333,13 +339,14 @@ class LedgerAPI(LedgerAPIDeprecated):
@async_set_httpx_client @async_set_httpx_client
@async_ensure_mint_loaded @async_ensure_mint_loaded
async def mint( async def mint(
self, outputs: List[BlindedMessage], quote: str self, outputs: List[BlindedMessage], quote: str, signature: Optional[str] = None
) -> List[BlindedSignature]: ) -> List[BlindedSignature]:
"""Mints new coins and returns a proof of promise. """Mints new coins and returns a proof of promise.
Args: Args:
outputs (List[BlindedMessage]): Outputs to mint new tokens with outputs (List[BlindedMessage]): Outputs to mint new tokens with
quote (str): Quote ID. quote (str): Quote ID.
signature (Optional[str], optional): NUT-19 signature of the request.
Returns: Returns:
list[Proof]: List of proofs. list[Proof]: List of proofs.
@@ -347,16 +354,21 @@ class LedgerAPI(LedgerAPIDeprecated):
Raises: Raises:
Exception: If the minting fails 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") logger.trace("Checking Lightning invoice. POST /v1/mint/bolt11")
def _mintrequest_include_fields(outputs: List[BlindedMessage]): def _mintrequest_include_fields(outputs: List[BlindedMessage]):
"""strips away fields from the model that aren't necessary for the /mint""" """strips away fields from the model that aren't necessary for the /mint"""
outputs_include = {"id", "amount", "B_"} outputs_include = {"id", "amount", "B_"}
return { res = {
"quote": ..., "quote": ...,
"outputs": {i: outputs_include for i in range(len(outputs))}, "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 payload = outputs_payload.dict(include=_mintrequest_include_fields(outputs)) # type: ignore
resp = await self.httpx.post( resp = await self.httpx.post(

View File

@@ -1,4 +1,3 @@
import base64
import copy import copy
import threading import threading
import time import time
@@ -20,7 +19,6 @@ from ..core.base import (
WalletKeyset, WalletKeyset,
) )
from ..core.crypto import b_dhke from ..core.crypto import b_dhke
from ..core.crypto.keys import derive_keyset_id
from ..core.crypto.secp import PrivateKey, PublicKey from ..core.crypto.secp import PrivateKey, PublicKey
from ..core.db import Database from ..core.db import Database
from ..core.errors import KeysetNotFoundError from ..core.errors import KeysetNotFoundError
@@ -36,12 +34,14 @@ from ..core.models import (
PostCheckStateResponse, PostCheckStateResponse,
PostMeltQuoteResponse, PostMeltQuoteResponse,
) )
from ..core.nuts import nut20
from ..core.p2pk import Secret from ..core.p2pk import Secret
from ..core.settings import settings from ..core.settings import settings
from ..core.split import amount_split
from . import migrations from . import migrations
from .compat import WalletCompat
from .crud import ( from .crud import (
bump_secret_derivation, bump_secret_derivation,
get_bolt11_mint_quote,
get_keysets, get_keysets,
get_proofs, get_proofs,
invalidate_proof, invalidate_proof,
@@ -68,7 +68,13 @@ from .v1_api import LedgerAPI
class Wallet( class Wallet(
LedgerAPI, WalletP2PK, WalletHTLC, WalletSecrets, WalletTransactions, WalletProofs LedgerAPI,
WalletP2PK,
WalletHTLC,
WalletSecrets,
WalletTransactions,
WalletProofs,
WalletCompat,
): ):
""" """
Nutshell wallet class. Nutshell wallet class.
@@ -248,39 +254,6 @@ class Wallet(
await self.load_keysets_from_db() 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: async def activate_keyset(self, keyset_id: Optional[str] = None) -> None:
"""Activates a keyset by setting self.keyset_id. Either activates a specific keyset """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. 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.") logger.trace("Secret check complete.")
async def request_mint_with_callback( 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]: ) -> Tuple[MintQuote, SubscriptionManager]:
"""Request a quote invoice for minting tokens. """Request a quote invoice for minting tokens.
@@ -398,84 +374,56 @@ class Wallet(
Returns: Returns:
MintQuote: Mint Quote 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) subscriptions = SubscriptionManager(self.url)
threading.Thread( threading.Thread(
target=subscriptions.connect, name="SubscriptionManager", daemon=True target=subscriptions.connect, name="SubscriptionManager", daemon=True
).start() ).start()
subscriptions.subscribe( subscriptions.subscribe(
kind=JSONRPCSubscriptionKinds.BOLT11_MINT_QUOTE, kind=JSONRPCSubscriptionKinds.BOLT11_MINT_QUOTE,
filters=[mint_qoute.quote], filters=[mint_quote.quote],
callback=callback, 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) await store_bolt11_mint_quote(db=self.db, quote=quote)
return quote, subscriptions 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. """Request a quote invoice for minting tokens.
Args: Args:
amount (int): Amount for Lightning invoice in satoshis 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. 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. 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: Returns:
MintQuote: Mint Quote 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( quote = MintQuote.from_resp_wallet(
mint_quote_response, self.url, amount, self.unit.name mint_quote_response, self.url, amount, self.unit.name
) )
quote.privkey = privkey_hex
await store_bolt11_mint_quote(db=self.db, quote=quote) await store_bolt11_mint_quote(db=self.db, quote=quote)
return 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( async def mint(
self, self,
amount: int, amount: int,
@@ -509,8 +457,6 @@ class Wallet(
# split based on our wallet state # split based on our wallet state
amounts = split or self.split_wallet_state(amount) 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 # 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 # 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) await self._check_used_secrets(secrets)
outputs, rs = self._construct_outputs(amounts, secrets, rs) 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 # 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 promises_keyset_id = promises[0].id
await bump_secret_derivation( await bump_secret_derivation(
@@ -547,10 +500,7 @@ class Wallet(
self, self,
proofs: List[Proof], proofs: List[Proof],
) -> Tuple[List[Proof], List[Proof]]: ) -> Tuple[List[Proof], List[Proof]]:
"""Redeem proofs by sending them to yourself (by calling a split).) """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.
Args: Args:
proofs (List[Proof]): Proofs to be redeemed. proofs (List[Proof]): Proofs to be redeemed.
""" """
@@ -558,57 +508,6 @@ class Wallet(
self.verify_proofs_dleq(proofs) self.verify_proofs_dleq(proofs)
return await self.split(proofs=proofs, amount=0) 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( async def split(
self, self,
proofs: List[Proof], 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 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. 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: Args:
proofs (List[Proof]): Proofs to be split. proofs (List[Proof]): Proofs to be split.
amount (int): Amount to be sent. amount (int): Amount to be sent.

View File

@@ -261,6 +261,7 @@ class LedgerAPIDeprecated(SupportsHttpxClient, SupportsMintURL):
paid=False, paid=False,
state=MintQuoteState.unpaid.value, state=MintQuoteState.unpaid.value,
expiry=decoded_invoice.date + (decoded_invoice.expiry or 0), expiry=decoded_invoice.date + (decoded_invoice.expiry or 0),
pubkey=None
) )
@async_set_httpx_client @async_set_httpx_client

View File

@@ -14,7 +14,8 @@ from cashu.core.models import (
PostRestoreRequest, PostRestoreRequest,
PostRestoreResponse, 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.core.settings import settings
from cashu.mint.ledger import Ledger from cashu.mint.ledger import Ledger
from cashu.wallet.crud import bump_secret_derivation 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): async def test_mint_quote(ledger: Ledger):
response = httpx.post( response = httpx.post(
f"{BASE_URL}/v1/mint/quote/bolt11", 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}" assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json() result = response.json()
assert result["quote"] assert result["quote"]
assert result["request"] assert result["request"]
assert result["pubkey"] == "02" + "00" * 32
# deserialize the response # deserialize the response
resp_quote = PostMintQuoteResponse(**result) resp_quote = PostMintQuoteResponse(**result)
@@ -232,6 +234,7 @@ async def test_mint_quote(ledger: Ledger):
# check if DEPRECATED paid flag is also returned # check if DEPRECATED paid flag is also returned
assert result2["paid"] is True assert result2["paid"] is True
assert resp_quote.paid is True assert resp_quote.paid is True
assert resp_quote.pubkey == "02" + "00" * 32
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -244,10 +247,16 @@ async def test_mint(ledger: Ledger, wallet: Wallet):
await pay_if_regtest(mint_quote.request) await pay_if_regtest(mint_quote.request)
secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) 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] outputs_payload = [o.dict() for o in outputs]
response = httpx.post( response = httpx.post(
f"{BASE_URL}/v1/mint/bolt11", 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, timeout=None,
) )
assert response.status_code == 200, f"{response.url} {response.status_code}" 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"] 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.asyncio
@pytest.mark.skipif( @pytest.mark.skipif(
settings.debug_mint_only_deprecated, settings.debug_mint_only_deprecated,

View File

@@ -2,6 +2,7 @@ import httpx
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from cashu.core.nuts import nut20
from cashu.core.settings import settings from cashu.core.settings import settings
from cashu.mint.ledger import Ledger from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet
@@ -29,21 +30,23 @@ async def wallet(ledger: Ledger):
) )
async def test_api_mint_cached_responses(wallet: Wallet): async def test_api_mint_cached_responses(wallet: Wallet):
# Testing mint # Testing mint
invoice = await wallet.request_mint(64) mint_quote = await wallet.request_mint(64)
await pay_if_regtest(invoice.request) 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) secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10010, 10011)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) 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] outputs_payload = [o.dict() for o in outputs]
response = httpx.post( response = httpx.post(
f"{BASE_URL}/v1/mint/bolt11", f"{BASE_URL}/v1/mint/bolt11",
json={"quote": quote_id, "outputs": outputs_payload}, json={"quote": quote_id, "outputs": outputs_payload, "signature": signature},
timeout=None, timeout=None,
) )
response1 = httpx.post( response1 = httpx.post(
f"{BASE_URL}/v1/mint/bolt11", f"{BASE_URL}/v1/mint/bolt11",
json={"quote": quote_id, "outputs": outputs_payload}, json={"quote": quote_id, "outputs": outputs_payload, "signature": signature},
timeout=None, timeout=None,
) )
assert response.status_code == 200, f"{response.status_code = }" 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", reason="settings.mint_redis_cache_enabled is False",
) )
async def test_api_swap_cached_responses(wallet: Wallet): async def test_api_swap_cached_responses(wallet: Wallet):
quote = await wallet.request_mint(64) mint_quote = await wallet.request_mint(64)
await pay_if_regtest(quote.request) 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) secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10010, 10011)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) 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] inputs_payload = [i.dict() for i in minted]
outputs_payload = [o.dict() for o in outputs] outputs_payload = [o.dict() for o in outputs]
response = httpx.post( response = httpx.post(
f"{BASE_URL}/v1/swap", f"{BASE_URL}/v1/swap",
json={"inputs": inputs_payload, "outputs": outputs_payload}, json={
"inputs": inputs_payload,
"outputs": outputs_payload,
"signature": signature,
},
timeout=None, timeout=None,
) )
response1 = httpx.post( response1 = httpx.post(
@@ -86,15 +95,14 @@ async def test_api_swap_cached_responses(wallet: Wallet):
reason="settings.mint_redis_cache_enabled is False", reason="settings.mint_redis_cache_enabled is False",
) )
async def test_api_melt_cached_responses(wallet: Wallet): 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) melt_quote = await wallet.melt_quote(invoice_32sat)
await pay_if_regtest(quote.request) 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, 10010) secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10010, 10010)
outputs, rs = wallet._construct_outputs([32], secrets, rs) outputs, rs = wallet._construct_outputs([32], secrets, rs)
inputs_payload = [i.dict() for i in minted] inputs_payload = [i.dict() for i in minted]
outputs_payload = [o.dict() for o in outputs] outputs_payload = [o.dict() for o in outputs]
response = httpx.post( response = httpx.post(

View File

@@ -6,6 +6,7 @@ from cashu.core.base import Proof, Unit
from cashu.core.models import ( from cashu.core.models import (
CheckSpendableRequest_deprecated, CheckSpendableRequest_deprecated,
CheckSpendableResponse_deprecated, CheckSpendableResponse_deprecated,
GetMintResponse_deprecated,
PostRestoreRequest, PostRestoreRequest,
PostRestoreResponse, PostRestoreResponse,
) )
@@ -124,15 +125,20 @@ async def test_api_mint_validation(ledger):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mint(ledger: Ledger, wallet: Wallet): async def test_mint(ledger: Ledger, wallet: Wallet):
mint_quote = await wallet.request_mint(64) quote_response = httpx.get(
await pay_if_regtest(mint_quote.request) 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) secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) outputs, rs = wallet._construct_outputs([32, 32], secrets, rs)
outputs_payload = [o.dict() for o in outputs] outputs_payload = [o.dict() for o in outputs]
response = httpx.post( response = httpx.post(
f"{BASE_URL}/mint", f"{BASE_URL}/mint",
json={"outputs": outputs_payload}, json={"outputs": outputs_payload},
params={"hash": mint_quote.quote}, params={"hash": mint_quote.hash},
timeout=None, timeout=None,
) )
assert response.status_code == 200, f"{response.url} {response.status_code}" assert response.status_code == 200, f"{response.url} {response.status_code}"

View File

@@ -4,6 +4,7 @@ import pytest_asyncio
from cashu.core.base import MeltQuoteState from cashu.core.base import MeltQuoteState
from cashu.core.helpers import sum_proofs from cashu.core.helpers import sum_proofs
from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest
from cashu.core.nuts import nut20
from cashu.core.settings import settings from cashu.core.settings import settings
from cashu.mint.ledger import Ledger from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet
@@ -119,9 +120,9 @@ async def test_melt_external(wallet1: Wallet, ledger: Ledger):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet") @pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
async def test_mint_internal(wallet1: Wallet, ledger: Ledger): async def test_mint_internal(wallet1: Wallet, ledger: Ledger):
mint_quote = await wallet1.request_mint(128) wallet_mint_quote = await wallet1.request_mint(128)
await ledger.get_mint_quote(mint_quote.quote) await ledger.get_mint_quote(wallet_mint_quote.quote)
mint_quote = await ledger.get_mint_quote(mint_quote.quote) mint_quote = await ledger.get_mint_quote(wallet_mint_quote.quote)
assert mint_quote.paid, "mint quote should be paid" 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) len(output_amounts)
) )
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs) 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( await assert_err(
ledger.mint(outputs=outputs, quote_id=mint_quote.quote), 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.asyncio
@pytest.mark.skipif(is_fake, reason="only works with Regtest") @pytest.mark.skipif(is_fake, reason="only works with Regtest")
async def test_mint_external(wallet1: Wallet, ledger: Ledger): async def test_mint_external(wallet1: Wallet, ledger: Ledger):
quote = await ledger.mint_quote(PostMintQuoteRequest(amount=128, unit="sat")) quote = await wallet1.request_mint(128)
assert not quote.paid, "mint quote should not be paid"
assert quote.unpaid
mint_quote = await ledger.get_mint_quote(quote.quote) mint_quote = await ledger.get_mint_quote(quote.quote)
assert not mint_quote.paid, "mint quote already paid" 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) len(output_amounts)
) )
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs) 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) mint_quote_after_payment = await ledger.get_mint_quote(quote.quote)
assert mint_quote_after_payment.issued, "mint quote should be issued" 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) len(output_amounts)
) )
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs) 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 # now try to mint with the same outputs again
mint_quote_2 = await wallet1.request_mint(128) mint_quote_2 = await wallet1.request_mint(128)
await pay_if_regtest(mint_quote_2.request) 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( 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.", "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 # we use the outputs once for minting
mint_quote_2 = await wallet1.request_mint(128) mint_quote_2 = await wallet1.request_mint(128)
await pay_if_regtest(mint_quote_2.request) 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 # use the same outputs for melting
mint_quote = await ledger.mint_quote(PostMintQuoteRequest(unit="sat", amount=128)) mint_quote = await ledger.mint_quote(PostMintQuoteRequest(unit="sat", amount=128))

View File

@@ -5,7 +5,7 @@ import pytest_asyncio
from cashu.core.base import Method, MintQuoteState, ProofState from cashu.core.base import Method, MintQuoteState, ProofState
from cashu.core.json_rpc.base import JSONRPCNotficationParams 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.core.settings import settings
from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT from tests.conftest import SERVER_ENDPOINT