mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-21 11:04:19 +01:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
0
cashu/core/nuts/__init__.py
Normal file
0
cashu/core/nuts/__init__.py
Normal file
41
cashu/core/nuts/nut20.py
Normal file
41
cashu/core/nuts/nut20.py
Normal 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)
|
||||||
@@ -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
|
||||||
@@ -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)))
|
|
||||||
|
|||||||
@@ -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 ""
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
49
cashu/wallet/compat.py
Normal 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
|
||||||
@@ -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 "",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user