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