Multinut LND (#492)

* amount in melt request

* apply fee limit

* more error handling

* wip: signal flag in /info

* clean up multinut

* decode mypy error lndrest

* fix test

* fix tests

* signal feature and blindmessages_deprecated

* setting

* fix blindedsignature method

* fix tests

* mint info file

* test mpp with lnd regtest

* nuts optionsl mint
 info

* try to enable mpp with lnd

* test mpp with third payment
This commit is contained in:
callebtc
2024-05-22 22:52:26 +02:00
committed by GitHub
parent 71b4051373
commit 61cf7def24
27 changed files with 502 additions and 110 deletions

View File

@@ -65,6 +65,7 @@ jobs:
MINT_LND_REST_ENDPOINT: https://localhost:8081/ MINT_LND_REST_ENDPOINT: https://localhost:8081/
MINT_LND_REST_CERT: ./regtest/data/lnd-3/tls.cert MINT_LND_REST_CERT: ./regtest/data/lnd-3/tls.cert
MINT_LND_REST_MACAROON: ./regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon MINT_LND_REST_MACAROON: ./regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
MINT_LND_ENABLE_MPP: true
# LND_GRPC_ENDPOINT: localhost # LND_GRPC_ENDPOINT: localhost
# LND_GRPC_PORT: 10009 # LND_GRPC_PORT: 10009
# LND_GRPC_CERT: ./regtest/data/lnd-3/tls.cert # LND_GRPC_CERT: ./regtest/data/lnd-3/tls.cert

View File

@@ -161,13 +161,18 @@ class Proof(BaseModel):
return HTLCWitness.from_witness(self.witness).preimage return HTLCWitness.from_witness(self.witness).preimage
class Proofs(BaseModel):
# NOTE: not used in Pydantic validation
__root__: List[Proof]
class BlindedMessage(BaseModel): class BlindedMessage(BaseModel):
""" """
Blinded message or blinded secret or "output" which is to be signed by the mint Blinded message or blinded secret or "output" which is to be signed by the mint
""" """
amount: int amount: int
id: str id: str # Keyset id
B_: str # Hex-encoded blinded message B_: str # Hex-encoded blinded message
witness: Union[str, None] = None # witnesses (used for P2PK with SIG_ALL) witness: Union[str, None] = None # witnesses (used for P2PK with SIG_ALL)
@@ -177,6 +182,28 @@ class BlindedMessage(BaseModel):
return P2PKWitness.from_witness(self.witness).signatures return P2PKWitness.from_witness(self.witness).signatures
class BlindedMessage_Deprecated(BaseModel):
"""
Deprecated: BlindedMessage for v0 protocol (deprecated api routes) have no id field.
Blinded message or blinded secret or "output" which is to be signed by the mint
"""
amount: int
B_: str # Hex-encoded blinded message
witness: Union[str, None] = None # witnesses (used for P2PK with SIG_ALL)
@property
def p2pksigs(self) -> List[str]:
assert self.witness, "Witness missing in output"
return P2PKWitness.from_witness(self.witness).signatures
class BlindedMessages(BaseModel):
# NOTE: not used in Pydantic validation
__root__: List[BlindedMessage] = []
class BlindedSignature(BaseModel): class BlindedSignature(BaseModel):
""" """
Blinded signature or "promise" which is the signature on a `BlindedMessage` Blinded signature or "promise" which is the signature on a `BlindedMessage`
@@ -314,7 +341,13 @@ class GetInfoResponse(BaseModel):
description_long: Optional[str] = None description_long: Optional[str] = None
contact: Optional[List[List[str]]] = None contact: Optional[List[List[str]]] = None
motd: Optional[str] = None motd: Optional[str] = None
nuts: Optional[Dict[int, Dict[str, Any]]] = None nuts: Optional[Dict[int, Any]] = None
class Nut15MppSupport(BaseModel):
method: str
unit: str
mpp: bool
class GetInfoResponse_deprecated(BaseModel): class GetInfoResponse_deprecated(BaseModel):
@@ -329,19 +362,6 @@ class GetInfoResponse_deprecated(BaseModel):
parameter: Optional[dict] = None parameter: Optional[dict] = None
class BlindedMessage_Deprecated(BaseModel):
# Same as BlindedMessage, but without the id field
amount: int
B_: str # Hex-encoded blinded message
id: Optional[str] = None
witness: Union[str, None] = None # witnesses (used for P2PK with SIG_ALL)
@property
def p2pksigs(self) -> List[str]:
assert self.witness, "Witness missing in output"
return P2PKWitness.from_witness(self.witness).signatures
# ------- API: KEYS ------- # ------- API: KEYS -------
@@ -425,6 +445,7 @@ class PostMeltQuoteRequest(BaseModel):
request: str = Field( request: str = Field(
..., max_length=settings.mint_max_request_length ..., max_length=settings.mint_max_request_length
) # output payment request ) # output payment request
amount: Optional[int] = Field(default=None, gt=0) # input amount
class PostMeltQuoteResponse(BaseModel): class PostMeltQuoteResponse(BaseModel):
@@ -551,6 +572,12 @@ class PostRestoreRequest(BaseModel):
) )
class PostRestoreRequest_Deprecated(BaseModel):
outputs: List[BlindedMessage_Deprecated] = Field(
..., max_items=settings.mint_max_request_length
)
class PostRestoreResponse(BaseModel): class PostRestoreResponse(BaseModel):
outputs: List[BlindedMessage] = [] outputs: List[BlindedMessage] = []
signatures: List[BlindedSignature] = [] signatures: List[BlindedSignature] = []
@@ -656,6 +683,7 @@ class WalletKeyset:
valid_to=None, valid_to=None,
first_seen=None, first_seen=None,
active=True, active=True,
use_deprecated_id=False, # BACKWARDS COMPATIBILITY < 0.15.0
): ):
self.valid_from = valid_from self.valid_from = valid_from
self.valid_to = valid_to self.valid_to = valid_to
@@ -670,10 +698,19 @@ class WalletKeyset:
else: else:
self.id = id self.id = id
# BEGIN BACKWARDS COMPATIBILITY < 0.15.0
if use_deprecated_id:
logger.warning(
"Using deprecated keyset id derivation for backwards compatibility <"
" 0.15.0"
)
self.id = derive_keyset_id_deprecated(self.public_keys)
# END BACKWARDS COMPATIBILITY < 0.15.0
self.unit = Unit[unit] self.unit = Unit[unit]
logger.trace(f"Derived keyset id {self.id} from public keys.") logger.trace(f"Derived keyset id {self.id} from public keys.")
if id and id != self.id: if id and id != self.id and use_deprecated_id:
logger.warning( logger.warning(
f"WARNING: Keyset id {self.id} does not match the given id {id}." f"WARNING: Keyset id {self.id} does not match the given id {id}."
" Overwriting." " Overwriting."
@@ -728,6 +765,8 @@ class MintKeyset:
first_seen: Optional[str] = None first_seen: Optional[str] = None
version: Optional[str] = None version: Optional[str] = None
duplicate_keyset_id: Optional[str] = None # BACKWARDS COMPATIBILITY < 0.15.0
def __init__( def __init__(
self, self,
*, *,
@@ -808,12 +847,6 @@ class MintKeyset:
assert self.seed, "seed not set" assert self.seed, "seed not set"
assert self.derivation_path, "derivation path not set" assert self.derivation_path, "derivation path not set"
# we compute the keyset id from the public keys only if it is not
# loaded from the database. This is to allow for backwards compatibility
# with old keysets with new id's and vice versa. This code can be removed
# if there are only new keysets in the mint (> 0.15.0)
id_in_db = self.id
if self.version_tuple < (0, 12): if self.version_tuple < (0, 12):
# WARNING: Broken key derivation for backwards compatibility with < 0.12 # WARNING: Broken key derivation for backwards compatibility with < 0.12
self.private_keys = derive_keys_backwards_compatible_insecure_pre_0_12( self.private_keys = derive_keys_backwards_compatible_insecure_pre_0_12(
@@ -824,8 +857,7 @@ class MintKeyset:
f"WARNING: Using weak key derivation for keyset {self.id} (backwards" f"WARNING: Using weak key derivation for keyset {self.id} (backwards"
" compatibility < 0.12)" " compatibility < 0.12)"
) )
# load from db or derive self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore
self.id = id_in_db or derive_keyset_id_deprecated(self.public_keys) # type: ignore
elif self.version_tuple < (0, 15): elif self.version_tuple < (0, 15):
self.private_keys = derive_keys_sha256(self.seed, self.derivation_path) self.private_keys = derive_keys_sha256(self.seed, self.derivation_path)
logger.trace( logger.trace(
@@ -833,13 +865,11 @@ class MintKeyset:
" compatibility < 0.15)" " compatibility < 0.15)"
) )
self.public_keys = derive_pubkeys(self.private_keys) # type: ignore self.public_keys = derive_pubkeys(self.private_keys) # type: ignore
# load from db or derive self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore
self.id = id_in_db or derive_keyset_id_deprecated(self.public_keys) # type: ignore
else: else:
self.private_keys = derive_keys(self.seed, self.derivation_path) self.private_keys = derive_keys(self.seed, self.derivation_path)
self.public_keys = derive_pubkeys(self.private_keys) # type: ignore self.public_keys = derive_pubkeys(self.private_keys) # type: ignore
# load from db or derive self.id = derive_keyset_id(self.public_keys) # type: ignore
self.id = id_in_db or derive_keyset_id(self.public_keys) # type: ignore
# ------- TOKEN ------- # ------- TOKEN -------

View File

@@ -89,6 +89,7 @@ class MintLimits(MintSettings):
) )
mint_max_request_length: int = Field( mint_max_request_length: int = Field(
default=1000, default=1000,
gt=0,
title="Maximum request length", title="Maximum request length",
description="Maximum length of REST API request arrays.", description="Maximum length of REST API request arrays.",
) )
@@ -100,16 +101,21 @@ class MintLimits(MintSettings):
) )
mint_max_peg_in: int = Field( mint_max_peg_in: int = Field(
default=None, default=None,
gt=0,
title="Maximum peg-in", title="Maximum peg-in",
description="Maximum amount for a mint operation.", description="Maximum amount for a mint operation.",
) )
mint_max_peg_out: int = Field( mint_max_peg_out: int = Field(
default=None, default=None,
gt=0,
title="Maximum peg-out", title="Maximum peg-out",
description="Maximum amount for a melt operation.", description="Maximum amount for a melt operation.",
) )
mint_max_balance: int = Field( mint_max_balance: int = Field(
default=None, title="Maximum mint balance", description="Maximum mint balance." default=None,
gt=0,
title="Maximum mint balance",
description="Maximum mint balance.",
) )
@@ -171,6 +177,7 @@ class LndRestFundingSource(MintSettings):
mint_lnd_rest_macaroon: Optional[str] = Field(default=None) mint_lnd_rest_macaroon: Optional[str] = Field(default=None)
mint_lnd_rest_admin_macaroon: Optional[str] = Field(default=None) mint_lnd_rest_admin_macaroon: Optional[str] = Field(default=None)
mint_lnd_rest_invoice_macaroon: Optional[str] = Field(default=None) mint_lnd_rest_invoice_macaroon: Optional[str] = Field(default=None)
mint_lnd_enable_mpp: bool = Field(default=False)
class CoreLightningRestFundingSource(MintSettings): class CoreLightningRestFundingSource(MintSettings):

View File

@@ -3,7 +3,12 @@ from typing import Coroutine, Optional, Union
from pydantic import BaseModel from pydantic import BaseModel
from ..core.base import Amount, MeltQuote, Unit from ..core.base import (
Amount,
MeltQuote,
PostMeltQuoteRequest,
Unit,
)
class StatusResponse(BaseModel): class StatusResponse(BaseModel):
@@ -62,6 +67,7 @@ class PaymentStatus(BaseModel):
class LightningBackend(ABC): class LightningBackend(ABC):
supports_mpp: bool = False
supported_units: set[Unit] supported_units: set[Unit]
unit: Unit unit: Unit
@@ -107,7 +113,7 @@ class LightningBackend(ABC):
@abstractmethod @abstractmethod
async def get_payment_quote( async def get_payment_quote(
self, self,
bolt11: str, melt_quote: PostMeltQuoteRequest,
) -> PaymentQuoteResponse: ) -> PaymentQuoteResponse:
pass pass

View File

@@ -11,7 +11,7 @@ from bolt11 import (
) )
from loguru import logger from loguru import logger
from ..core.base import Amount, MeltQuote, Unit from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit
from ..core.settings import settings from ..core.settings import settings
from .base import ( from .base import (
InvoiceResponse, InvoiceResponse,
@@ -375,7 +375,10 @@ class BlinkWallet(LightningBackend):
preimage=preimage, preimage=preimage,
) )
async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: async def get_payment_quote(
self, melt_quote: PostMeltQuoteRequest
) -> PaymentQuoteResponse:
bolt11 = melt_quote.request
variables = { variables = {
"input": { "input": {
"paymentRequest": bolt11, "paymentRequest": bolt11,

View File

@@ -10,7 +10,7 @@ from bolt11 import (
) )
from loguru import logger from loguru import logger
from ..core.base import Amount, MeltQuote, Unit from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit
from ..core.helpers import fee_reserve from ..core.helpers import fee_reserve
from ..core.settings import settings from ..core.settings import settings
from .base import ( from .base import (
@@ -316,8 +316,10 @@ class CoreLightningRestWallet(LightningBackend):
) )
await asyncio.sleep(0.02) await asyncio.sleep(0.02)
async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: async def get_payment_quote(
invoice_obj = decode(bolt11) self, melt_quote: PostMeltQuoteRequest
) -> PaymentQuoteResponse:
invoice_obj = decode(melt_quote.request)
assert invoice_obj.amount_msat, "invoice has no amount." assert invoice_obj.amount_msat, "invoice has no amount."
amount_msat = int(invoice_obj.amount_msat) amount_msat = int(invoice_obj.amount_msat)
fees_msat = fee_reserve(amount_msat) fees_msat = fee_reserve(amount_msat)

View File

@@ -15,7 +15,7 @@ from bolt11 import (
encode, encode,
) )
from ..core.base import Amount, MeltQuote, Unit from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit
from ..core.helpers import fee_reserve from ..core.helpers import fee_reserve
from ..core.settings import settings from ..core.settings import settings
from .base import ( from .base import (
@@ -152,8 +152,10 @@ class FakeWallet(LightningBackend):
# amount = invoice_obj.amount_msat # amount = invoice_obj.amount_msat
# return InvoiceQuoteResponse(checking_id="", amount=amount) # return InvoiceQuoteResponse(checking_id="", amount=amount)
async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: async def get_payment_quote(
invoice_obj = decode(bolt11) self, melt_quote: PostMeltQuoteRequest
) -> PaymentQuoteResponse:
invoice_obj = decode(melt_quote.request)
assert invoice_obj.amount_msat, "invoice has no amount." assert invoice_obj.amount_msat, "invoice has no amount."
if self.unit == Unit.sat: if self.unit == Unit.sat:

View File

@@ -6,7 +6,7 @@ from bolt11 import (
decode, decode,
) )
from ..core.base import Amount, MeltQuote, Unit from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit
from ..core.helpers import fee_reserve from ..core.helpers import fee_reserve
from ..core.settings import settings from ..core.settings import settings
from .base import ( from .base import (
@@ -167,8 +167,10 @@ class LNbitsWallet(LightningBackend):
preimage=data["preimage"], preimage=data["preimage"],
) )
async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: async def get_payment_quote(
invoice_obj = decode(bolt11) self, melt_quote: PostMeltQuoteRequest
) -> PaymentQuoteResponse:
invoice_obj = decode(melt_quote.request)
assert invoice_obj.amount_msat, "invoice has no amount." assert invoice_obj.amount_msat, "invoice has no amount."
amount_msat = int(invoice_obj.amount_msat) amount_msat = int(invoice_obj.amount_msat)
fees_msat = fee_reserve(amount_msat) fees_msat = fee_reserve(amount_msat)

View File

@@ -4,13 +4,15 @@ import hashlib
import json import json
from typing import AsyncGenerator, Dict, Optional from typing import AsyncGenerator, Dict, Optional
import bolt11
import httpx import httpx
from bolt11 import ( from bolt11 import (
TagChar,
decode, decode,
) )
from loguru import logger from loguru import logger
from ..core.base import Amount, MeltQuote, Unit from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit
from ..core.helpers import fee_reserve from ..core.helpers import fee_reserve
from ..core.settings import settings from ..core.settings import settings
from .base import ( from .base import (
@@ -27,6 +29,7 @@ from .macaroon import load_macaroon
class LndRestWallet(LightningBackend): class LndRestWallet(LightningBackend):
"""https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" """https://api.lightning.community/rest/index.html#lnd-rest-api-reference"""
supports_mpp = settings.mint_lnd_enable_mpp
supported_units = set([Unit.sat, Unit.msat]) supported_units = set([Unit.sat, Unit.msat])
unit = Unit.sat unit = Unit.sat
@@ -70,6 +73,8 @@ class LndRestWallet(LightningBackend):
self.client = httpx.AsyncClient( self.client = httpx.AsyncClient(
base_url=self.endpoint, headers=self.auth, verify=self.cert base_url=self.endpoint, headers=self.auth, verify=self.cert
) )
if self.supports_mpp:
logger.info("LNDRestWallet enabling MPP feature")
async def status(self) -> StatusResponse: async def status(self) -> StatusResponse:
try: try:
@@ -148,6 +153,16 @@ class LndRestWallet(LightningBackend):
async def pay_invoice( async def pay_invoice(
self, quote: MeltQuote, fee_limit_msat: int self, quote: MeltQuote, fee_limit_msat: int
) -> PaymentResponse: ) -> PaymentResponse:
# if the amount of the melt quote is different from the request
# call pay_partial_invoice instead
invoice = bolt11.decode(quote.request)
if invoice.amount_msat:
amount_msat = int(invoice.amount_msat)
if amount_msat != quote.amount * 1000 and self.supports_mpp:
return await self.pay_partial_invoice(
quote, Amount(Unit.sat, quote.amount), fee_limit_msat
)
# set the fee limit for the payment # set the fee limit for the payment
lnrpcFeeLimit = dict() lnrpcFeeLimit = dict()
lnrpcFeeLimit["fixed_msat"] = f"{fee_limit_msat}" lnrpcFeeLimit["fixed_msat"] = f"{fee_limit_msat}"
@@ -180,6 +195,91 @@ class LndRestWallet(LightningBackend):
error_message=None, error_message=None,
) )
async def pay_partial_invoice(
self, quote: MeltQuote, amount: Amount, fee_limit_msat: int
) -> PaymentResponse:
# set the fee limit for the payment
lnrpcFeeLimit = dict()
lnrpcFeeLimit["fixed_msat"] = f"{fee_limit_msat}"
invoice = bolt11.decode(quote.request)
invoice_amount = invoice.amount_msat
assert invoice_amount, "invoice has no amount."
total_amount_msat = int(invoice_amount)
payee = invoice.tags.get(TagChar.payee)
assert payee
pubkey = str(payee.data)
payer_addr_tag = invoice.tags.get(bolt11.TagChar("s"))
assert payer_addr_tag
payer_addr = str(payer_addr_tag.data)
# get the route
r = await self.client.post(
url=f"/v1/graph/routes/{pubkey}/{amount.to(Unit.sat).amount}",
json={"fee_limit": lnrpcFeeLimit},
timeout=None,
)
data = r.json()
if r.is_error or data.get("message"):
error_message = data.get("message") or r.text
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
error_message=error_message,
)
# We need to set the mpp_record for a partial payment
mpp_record = {
"mpp_record": {
"payment_addr": base64.b64encode(bytes.fromhex(payer_addr)).decode(),
"total_amt_msat": total_amount_msat,
}
}
# add the mpp_record to the last hop
rout_nr = 0
data["routes"][rout_nr]["hops"][-1].update(mpp_record)
# send to route
r = await self.client.post(
url="/v2/router/route/send",
json={
"payment_hash": base64.b64encode(
bytes.fromhex(invoice.payment_hash)
).decode(),
"route": data["routes"][rout_nr],
},
timeout=None,
)
data = r.json()
if r.is_error or data.get("message"):
error_message = data.get("message") or r.text
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
error_message=error_message,
)
ok = data.get("status") == "SUCCEEDED"
checking_id = invoice.payment_hash
fee_msat = int(data["route"]["total_fees_msat"])
preimage = base64.b64decode(data["preimage"]).hex()
return PaymentResponse(
ok=ok,
checking_id=checking_id,
fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
preimage=preimage,
error_message=None,
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get(url=f"/v1/invoice/{checking_id}") r = await self.client.get(url=f"/v1/invoice/{checking_id}")
@@ -270,13 +370,29 @@ class LndRestWallet(LightningBackend):
) )
await asyncio.sleep(5) await asyncio.sleep(5)
async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: async def get_payment_quote(
invoice_obj = decode(bolt11) self, melt_quote: PostMeltQuoteRequest
) -> PaymentQuoteResponse:
# get amount from melt_quote or from bolt11
amount = (
Amount(Unit[melt_quote.unit], melt_quote.amount)
if melt_quote.amount
else None
)
invoice_obj = decode(melt_quote.request)
assert invoice_obj.amount_msat, "invoice has no amount." assert invoice_obj.amount_msat, "invoice has no amount."
if amount:
amount_msat = amount.to(Unit.msat).amount
else:
amount_msat = int(invoice_obj.amount_msat) amount_msat = int(invoice_obj.amount_msat)
fees_msat = fee_reserve(amount_msat) fees_msat = fee_reserve(amount_msat)
fees = Amount(unit=Unit.msat, amount=fees_msat) fees = Amount(unit=Unit.msat, amount=fees_msat)
amount = Amount(unit=Unit.msat, amount=amount_msat) amount = Amount(unit=Unit.msat, amount=amount_msat)
return PaymentQuoteResponse( return PaymentQuoteResponse(
checking_id=invoice_obj.payment_hash, checking_id=invoice_obj.payment_hash,
fee=fees.to(self.unit, round="up"), fee=fees.to(self.unit, round="up"),

View File

@@ -4,7 +4,7 @@ from typing import Dict, Optional
import httpx import httpx
from ..core.base import Amount, MeltQuote, Unit from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit
from ..core.settings import settings from ..core.settings import settings
from .base import ( from .base import (
InvoiceResponse, InvoiceResponse,
@@ -118,7 +118,10 @@ class StrikeUSDWallet(LightningBackend):
error_message=None, error_message=None,
) )
async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: async def get_payment_quote(
self, melt_quote: PostMeltQuoteRequest
) -> PaymentQuoteResponse:
bolt11 = melt_quote.request
try: try:
r = await self.client.post( r = await self.client.post(
url=f"{self.endpoint}/v1/payment-quotes/lightning", url=f"{self.endpoint}/v1/payment-quotes/lightning",

View File

@@ -575,9 +575,10 @@ class Ledger(LedgerVerification, LedgerSpendingConditions):
) )
else: else:
# not internal, get payment quote by backend # not internal, get payment quote by backend
payment_quote = await self.backends[method][unit].get_payment_quote(request) payment_quote = await self.backends[method][unit].get_payment_quote(
if not payment_quote.checking_id: melt_quote=melt_quote
raise TransactionError("quote has no checking id") )
assert payment_quote.checking_id, "quote has no checking id"
# make sure the backend returned the amount with a correct unit # make sure the backend returned the amount with a correct unit
if not payment_quote.amount.unit == unit: if not payment_quote.amount.unit == unit:
raise TransactionError("payment quote amount units do not match") raise TransactionError("payment quote amount units do not match")

View File

@@ -62,7 +62,8 @@ async def info() -> GetInfoResponse:
supported_dict = dict(supported=True) supported_dict = dict(supported=True)
mint_features: Dict[int, Dict[str, Any]] = { supported_dict = dict(supported=True)
mint_features: Dict[int, Any] = {
4: dict( 4: dict(
methods=method_settings[4], methods=method_settings[4],
disabled=settings.mint_peg_out_only, disabled=settings.mint_peg_out_only,
@@ -79,6 +80,21 @@ async def info() -> GetInfoResponse:
12: supported_dict, 12: supported_dict,
} }
# signal which method-unit pairs support MPP
for method, unit_dict in ledger.backends.items():
for unit in unit_dict.keys():
logger.trace(
f"method={method.name} unit={unit} supports_mpp={unit_dict[unit].supports_mpp}"
)
if unit_dict[unit].supports_mpp:
mint_features.setdefault(15, []).append(
{
"method": method.name,
"unit": unit.name,
"mpp": True,
}
)
return GetInfoResponse( return GetInfoResponse(
name=settings.mint_info_name, name=settings.mint_info_name,
pubkey=ledger.pubkey.serialize().hex() if ledger.pubkey else None, pubkey=ledger.pubkey.serialize().hex() if ledger.pubkey else None,

View File

@@ -20,7 +20,7 @@ from ..core.base import (
PostMintQuoteRequest, PostMintQuoteRequest,
PostMintRequest_deprecated, PostMintRequest_deprecated,
PostMintResponse_deprecated, PostMintResponse_deprecated,
PostRestoreRequest, PostRestoreRequest_Deprecated,
PostRestoreResponse, PostRestoreResponse,
PostSplitRequest_Deprecated, PostSplitRequest_Deprecated,
PostSplitResponse_Deprecated, PostSplitResponse_Deprecated,
@@ -179,7 +179,7 @@ async def mint_deprecated(
# BEGIN BACKWARDS COMPATIBILITY < 0.15 # BEGIN BACKWARDS COMPATIBILITY < 0.15
# Mint expects "id" in outputs to know which keyset to use to sign them. # Mint expects "id" in outputs to know which keyset to use to sign them.
outputs: list[BlindedMessage] = [ outputs: list[BlindedMessage] = [
BlindedMessage(id=o.id or ledger.keyset.id, **o.dict(exclude={"id"})) BlindedMessage(id=ledger.keyset.id, **o.dict(exclude={"id"}))
for o in payload.outputs for o in payload.outputs
] ]
# END BACKWARDS COMPATIBILITY < 0.15 # END BACKWARDS COMPATIBILITY < 0.15
@@ -223,7 +223,7 @@ async def melt_deprecated(
# BEGIN BACKWARDS COMPATIBILITY < 0.14: add "id" to outputs # BEGIN BACKWARDS COMPATIBILITY < 0.14: add "id" to outputs
if payload.outputs: if payload.outputs:
outputs: list[BlindedMessage] = [ outputs: list[BlindedMessage] = [
BlindedMessage(id=o.id or ledger.keyset.id, **o.dict(exclude={"id"})) BlindedMessage(id=ledger.keyset.id, **o.dict(exclude={"id"}))
for o in payload.outputs for o in payload.outputs
] ]
else: else:
@@ -295,7 +295,7 @@ async def split_deprecated(
assert payload.outputs, Exception("no outputs provided.") assert payload.outputs, Exception("no outputs provided.")
# BEGIN BACKWARDS COMPATIBILITY < 0.14: add "id" to outputs # BEGIN BACKWARDS COMPATIBILITY < 0.14: add "id" to outputs
outputs: list[BlindedMessage] = [ outputs: list[BlindedMessage] = [
BlindedMessage(id=o.id or ledger.keyset.id, **o.dict(exclude={"id"})) BlindedMessage(id=ledger.keyset.id, **o.dict(exclude={"id"}))
for o in payload.outputs for o in payload.outputs
] ]
# END BACKWARDS COMPATIBILITY < 0.14 # END BACKWARDS COMPATIBILITY < 0.14
@@ -372,7 +372,15 @@ async def check_spendable_deprecated(
), ),
deprecated=True, deprecated=True,
) )
async def restore(payload: PostRestoreRequest) -> PostRestoreResponse: async def restore(payload: PostRestoreRequest_Deprecated) -> PostRestoreResponse:
assert payload.outputs, Exception("no outputs provided.") assert payload.outputs, Exception("no outputs provided.")
outputs, promises = await ledger.restore(payload.outputs) if payload.outputs:
outputs: list[BlindedMessage] = [
BlindedMessage(id=ledger.keyset.id, **o.dict(exclude={"id"}))
for o in payload.outputs
]
else:
outputs = []
outputs, promises = await ledger.restore(outputs)
return PostRestoreResponse(outputs=outputs, signatures=promises) return PostRestoreResponse(outputs=outputs, signatures=promises)

View File

@@ -189,7 +189,7 @@ async def swap(
# pay invoice from outgoing mint # pay invoice from outgoing mint
await outgoing_wallet.load_proofs(reload=True) await outgoing_wallet.load_proofs(reload=True)
quote = await outgoing_wallet.get_pay_amount_with_fees(invoice.bolt11) quote = await outgoing_wallet.request_melt(invoice.bolt11)
total_amount = quote.amount + quote.fee_reserve total_amount = quote.amount + quote.fee_reserve
if outgoing_wallet.available_balance < total_amount: if outgoing_wallet.available_balance < total_amount:
raise Exception("balance too low") raise Exception("balance too low")
@@ -197,7 +197,7 @@ async def swap(
_, send_proofs = await outgoing_wallet.split_to_send( _, send_proofs = await outgoing_wallet.split_to_send(
outgoing_wallet.proofs, total_amount, set_reserved=True outgoing_wallet.proofs, total_amount, set_reserved=True
) )
await outgoing_wallet.pay_lightning( await outgoing_wallet.melt(
send_proofs, invoice.bolt11, quote.fee_reserve, quote.quote send_proofs, invoice.bolt11, quote.fee_reserve, quote.quote
) )

View File

@@ -177,16 +177,23 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool):
@cli.command("pay", help="Pay Lightning invoice.") @cli.command("pay", help="Pay Lightning invoice.")
@click.argument("invoice", type=str) @click.argument("invoice", type=str)
@click.argument(
"amount",
type=int,
required=False,
)
@click.option( @click.option(
"--yes", "-y", default=False, is_flag=True, help="Skip confirmation.", type=bool "--yes", "-y", default=False, is_flag=True, help="Skip confirmation.", type=bool
) )
@click.pass_context @click.pass_context
@coro @coro
async def pay(ctx: Context, invoice: str, yes: bool): async def pay(
ctx: Context, invoice: str, amount: Optional[int] = None, yes: bool = False
):
wallet: Wallet = ctx.obj["WALLET"] wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint() await wallet.load_mint()
await print_balance(ctx) await print_balance(ctx)
quote = await wallet.get_pay_amount_with_fees(invoice) quote = await wallet.request_melt(invoice, amount)
logger.debug(f"Quote: {quote}") logger.debug(f"Quote: {quote}")
total_amount = quote.amount + quote.fee_reserve total_amount = quote.amount + quote.fee_reserve
if not yes: if not yes:
@@ -209,7 +216,7 @@ async def pay(ctx: Context, invoice: str, yes: bool):
return return
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
try: try:
melt_response = await wallet.pay_lightning( melt_response = await wallet.melt(
send_proofs, invoice, quote.fee_reserve, quote.quote send_proofs, invoice, quote.fee_reserve, quote.quote
) )
except Exception as e: except Exception as e:
@@ -334,14 +341,14 @@ async def swap(ctx: Context):
invoice = await incoming_wallet.request_mint(amount) invoice = await incoming_wallet.request_mint(amount)
# pay invoice from outgoing mint # pay invoice from outgoing mint
quote = await outgoing_wallet.get_pay_amount_with_fees(invoice.bolt11) quote = await outgoing_wallet.request_melt(invoice.bolt11)
total_amount = quote.amount + quote.fee_reserve total_amount = quote.amount + quote.fee_reserve
if outgoing_wallet.available_balance < total_amount: if outgoing_wallet.available_balance < total_amount:
raise Exception("balance too low") raise Exception("balance too low")
_, send_proofs = await outgoing_wallet.split_to_send( _, send_proofs = await outgoing_wallet.split_to_send(
outgoing_wallet.proofs, total_amount, set_reserved=True outgoing_wallet.proofs, total_amount, set_reserved=True
) )
await outgoing_wallet.pay_lightning( await outgoing_wallet.melt(
send_proofs, invoice.bolt11, quote.fee_reserve, quote.quote send_proofs, invoice.bolt11, quote.fee_reserve, quote.quote
) )

View File

@@ -55,7 +55,7 @@ class LightningWallet(Wallet):
Returns: Returns:
bool: True if successful bool: True if successful
""" """
quote = await self.get_pay_amount_with_fees(pr) quote = await self.request_melt(pr)
total_amount = quote.amount + quote.fee_reserve total_amount = quote.amount + quote.fee_reserve
assert total_amount > 0, "amount is not positive" assert total_amount > 0, "amount is not positive"
if self.available_balance < total_amount: if self.available_balance < total_amount:
@@ -63,9 +63,7 @@ class LightningWallet(Wallet):
return PaymentResponse(ok=False) return PaymentResponse(ok=False)
_, send_proofs = await self.split_to_send(self.proofs, total_amount) _, send_proofs = await self.split_to_send(self.proofs, total_amount)
try: try:
resp = await self.pay_lightning( resp = await self.melt(send_proofs, pr, quote.fee_reserve, quote.quote)
send_proofs, pr, quote.fee_reserve, quote.quote
)
if resp.change: if resp.change:
fees_paid_sat = quote.fee_reserve - sum_promises(resp.change) fees_paid_sat = quote.fee_reserve - sum_promises(resp.change)
else: else:

38
cashu/wallet/mint_info.py Normal file
View File

@@ -0,0 +1,38 @@
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
from ..core.base import Nut15MppSupport, Unit
class MintInfo(BaseModel):
name: Optional[str]
pubkey: Optional[str]
version: Optional[str]
description: Optional[str]
description_long: Optional[str]
contact: Optional[List[List[str]]]
motd: Optional[str]
nuts: Optional[Dict[int, Any]]
def __str__(self):
return f"{self.name} ({self.description})"
def supports_nut(self, nut: int) -> bool:
if self.nuts is None:
return False
return nut in self.nuts
def supports_mpp(self, method: str, unit: Unit) -> bool:
if not self.nuts:
return False
nut_15 = self.nuts.get(15)
if not nut_15 or not self.supports_nut(15):
return False
for entry in nut_15:
entry_obj = Nut15MppSupport.parse_obj(entry)
if entry_obj.method == method and entry_obj.unit == unit.name:
return True
return False

View File

@@ -71,6 +71,7 @@ from ..wallet.crud import (
) )
from . import migrations from . import migrations
from .htlc import WalletHTLC from .htlc import WalletHTLC
from .mint_info import MintInfo
from .p2pk import WalletP2PK from .p2pk import WalletP2PK
from .secrets import WalletSecrets from .secrets import WalletSecrets
from .wallet_deprecated import LedgerAPIDeprecated from .wallet_deprecated import LedgerAPIDeprecated
@@ -130,7 +131,7 @@ class LedgerAPI(LedgerAPIDeprecated, object):
keysets: Dict[str, WalletKeyset] # holds keysets keysets: Dict[str, WalletKeyset] # holds keysets
mint_keyset_ids: List[str] # holds active keyset ids of the mint mint_keyset_ids: List[str] # holds active keyset ids of the mint
unit: Unit unit: Unit
mint_info: GetInfoResponse # holds info about mint mint_info: MintInfo # holds info about mint
tor: TorProxy tor: TorProxy
db: Database db: Database
httpx: httpx.AsyncClient httpx: httpx.AsyncClient
@@ -269,9 +270,10 @@ class LedgerAPI(LedgerAPIDeprecated, object):
logger.debug(f"Mint keysets: {self.mint_keyset_ids}") logger.debug(f"Mint keysets: {self.mint_keyset_ids}")
return self.mint_keyset_ids return self.mint_keyset_ids
async def _load_mint_info(self) -> GetInfoResponse: async def _load_mint_info(self) -> MintInfo:
"""Loads the mint info from the mint.""" """Loads the mint info from the mint."""
self.mint_info = await self._get_info() mint_info_resp = await self._get_info()
self.mint_info = MintInfo(**mint_info_resp.dict())
logger.debug(f"Mint info: {self.mint_info}") logger.debug(f"Mint info: {self.mint_info}")
return self.mint_info return self.mint_info
@@ -518,11 +520,15 @@ class LedgerAPI(LedgerAPIDeprecated, object):
@async_set_httpx_client @async_set_httpx_client
@async_ensure_mint_loaded @async_ensure_mint_loaded
async def melt_quote(self, payment_request: str) -> PostMeltQuoteResponse: async def melt_quote(
self, payment_request: str, amount: Optional[int] = None
) -> PostMeltQuoteResponse:
"""Checks whether the Lightning payment is internal.""" """Checks whether the Lightning payment is internal."""
invoice_obj = bolt11.decode(payment_request) invoice_obj = bolt11.decode(payment_request)
assert invoice_obj.amount_msat, "invoice must have amount" assert invoice_obj.amount_msat, "invoice must have amount"
payload = PostMeltQuoteRequest(unit=self.unit.name, request=payment_request) payload = PostMeltQuoteRequest(
unit=self.unit.name, request=payment_request, amount=amount
)
resp = await self.httpx.post( resp = await self.httpx.post(
join(self.url, "/v1/melt/quote/bolt11"), join(self.url, "/v1/melt/quote/bolt11"),
json=payload.dict(), json=payload.dict(),
@@ -536,7 +542,7 @@ class LedgerAPI(LedgerAPIDeprecated, object):
quote_id = "deprecated_" + str(uuid.uuid4()) quote_id = "deprecated_" + str(uuid.uuid4())
return PostMeltQuoteResponse( return PostMeltQuoteResponse(
quote=quote_id, quote=quote_id,
amount=invoice_obj.amount_msat // 1000, amount=amount or invoice_obj.amount_msat // 1000,
fee_reserve=ret.fee or 0, fee_reserve=ret.fee or 0,
paid=False, paid=False,
expiry=invoice_obj.expiry, expiry=invoice_obj.expiry,
@@ -582,7 +588,7 @@ class LedgerAPI(LedgerAPIDeprecated, object):
if resp.status_code == 404: if resp.status_code == 404:
invoice = await get_lightning_invoice(id=quote, db=self.db) invoice = await get_lightning_invoice(id=quote, db=self.db)
assert invoice, f"no invoice found for id {quote}" assert invoice, f"no invoice found for id {quote}"
ret: PostMeltResponse_deprecated = await self.pay_lightning_deprecated( ret: PostMeltResponse_deprecated = await self.melt_deprecated(
proofs=proofs, outputs=outputs, invoice=invoice.bolt11 proofs=proofs, outputs=outputs, invoice=invoice.bolt11
) )
return PostMeltResponse( return PostMeltResponse(
@@ -987,7 +993,21 @@ class Wallet(LedgerAPI, WalletP2PK, WalletHTLC, WalletSecrets):
send_proofs = new_proofs[len(frst_outputs) :] send_proofs = new_proofs[len(frst_outputs) :]
return keep_proofs, send_proofs return keep_proofs, send_proofs
async def pay_lightning( async def request_melt(
self, invoice: str, amount: Optional[int] = None
) -> PostMeltQuoteResponse:
"""
Fetches a melt quote from the mint and either uses the amount in the invoice or the amount provided.
"""
if amount and not self.mint_info.supports_mpp("bolt11", self.unit):
raise Exception("Mint does not support MPP, cannot specify amount.")
melt_quote = await self.melt_quote(invoice, amount)
logger.debug(
f"Mint wants {self.unit.str(melt_quote.fee_reserve)} as fee reserve."
)
return melt_quote
async def melt(
self, proofs: List[Proof], invoice: str, fee_reserve_sat: int, quote_id: str self, proofs: List[Proof], invoice: str, fee_reserve_sat: int, quote_id: str
) -> PostMeltResponse: ) -> PostMeltResponse:
"""Pays a lightning invoice and returns the status of the payment. """Pays a lightning invoice and returns the status of the payment.
@@ -1520,17 +1540,6 @@ class Wallet(LedgerAPI, WalletP2PK, WalletHTLC, WalletSecrets):
# ---------- TRANSACTION HELPERS ---------- # ---------- TRANSACTION HELPERS ----------
async def get_pay_amount_with_fees(self, invoice: str):
"""
Decodes the amount from a Lightning invoice and returns the
total amount (amount+fees) to be paid.
"""
melt_quote = await self.melt_quote(invoice)
logger.debug(
f"Mint wants {self.unit.str(melt_quote.fee_reserve)} as fee reserve."
)
return melt_quote
async def split_to_send( async def split_to_send(
self, self,
proofs: List[Proof], proofs: List[Proof],

View File

@@ -298,7 +298,7 @@ class LedgerAPIDeprecated(SupportsHttpxClient, SupportsMintURL):
@async_set_httpx_client @async_set_httpx_client
@async_ensure_mint_loaded_deprecated @async_ensure_mint_loaded_deprecated
async def pay_lightning_deprecated( async def melt_deprecated(
self, proofs: List[Proof], invoice: str, outputs: Optional[List[BlindedMessage]] self, proofs: List[Proof], invoice: str, outputs: Optional[List[BlindedMessage]]
): ):
""" """

View File

@@ -44,6 +44,7 @@ settings.mint_derivation_path_list = []
settings.mint_private_key = "TEST_PRIVATE_KEY" settings.mint_private_key = "TEST_PRIVATE_KEY"
settings.mint_seed_decryption_key = "" settings.mint_seed_decryption_key = ""
settings.mint_max_balance = 0 settings.mint_max_balance = 0
settings.mint_lnd_enable_mpp = True
assert "test" in settings.cashu_dir assert "test" in settings.cashu_dir
shutil.rmtree(settings.cashu_dir, ignore_errors=True) shutil.rmtree(settings.cashu_dir, ignore_errors=True)

View File

@@ -249,11 +249,11 @@ async def test_startup_regtest_pending_quote_pending(wallet: Wallet, ledger: Led
invoice_payment_request = str(invoice_dict["payment_request"]) invoice_payment_request = str(invoice_dict["payment_request"])
# wallet pays the invoice # wallet pays the invoice
quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) quote = await wallet.melt_quote(invoice_payment_request)
total_amount = quote.amount + quote.fee_reserve total_amount = quote.amount + quote.fee_reserve
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
asyncio.create_task( asyncio.create_task(
wallet.pay_lightning( wallet.melt(
proofs=send_proofs, proofs=send_proofs,
invoice=invoice_payment_request, invoice=invoice_payment_request,
fee_reserve_sat=quote.fee_reserve, fee_reserve_sat=quote.fee_reserve,
@@ -294,11 +294,11 @@ async def test_startup_regtest_pending_quote_success(wallet: Wallet, ledger: Led
invoice_payment_request = str(invoice_dict["payment_request"]) invoice_payment_request = str(invoice_dict["payment_request"])
# wallet pays the invoice # wallet pays the invoice
quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) quote = await wallet.melt_quote(invoice_payment_request)
total_amount = quote.amount + quote.fee_reserve total_amount = quote.amount + quote.fee_reserve
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
asyncio.create_task( asyncio.create_task(
wallet.pay_lightning( wallet.melt(
proofs=send_proofs, proofs=send_proofs,
invoice=invoice_payment_request, invoice=invoice_payment_request,
fee_reserve_sat=quote.fee_reserve, fee_reserve_sat=quote.fee_reserve,
@@ -344,11 +344,11 @@ async def test_startup_regtest_pending_quote_failure(wallet: Wallet, ledger: Led
preimage_hash = invoice_obj.payment_hash preimage_hash = invoice_obj.payment_hash
# wallet pays the invoice # wallet pays the invoice
quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) quote = await wallet.melt_quote(invoice_payment_request)
total_amount = quote.amount + quote.fee_reserve total_amount = quote.amount + quote.fee_reserve
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
asyncio.create_task( asyncio.create_task(
wallet.pay_lightning( wallet.melt(
proofs=send_proofs, proofs=send_proofs,
invoice=invoice_payment_request, invoice=invoice_payment_request,
fee_reserve_sat=quote.fee_reserve, fee_reserve_sat=quote.fee_reserve,

View File

@@ -2,7 +2,7 @@ import pytest
import respx import respx
from httpx import Response from httpx import Response
from cashu.core.base import Amount, MeltQuote, Unit from cashu.core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit
from cashu.core.settings import settings from cashu.core.settings import settings
from cashu.lightning.blink import MINIMUM_FEE_MSAT, BlinkWallet # type: ignore from cashu.lightning.blink import MINIMUM_FEE_MSAT, BlinkWallet # type: ignore
@@ -192,7 +192,10 @@ async def test_blink_get_payment_quote():
# response says 1 sat fees but invoice (1000 sat) * 0.5% is 5 sat so we expect 5 sat # response says 1 sat fees but invoice (1000 sat) * 0.5% is 5 sat so we expect 5 sat
mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 1}}} mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 1}}}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
quote = await blink.get_payment_quote(payment_request) melt_quote_request = PostMeltQuoteRequest(
unit=Unit.sat.name, request=payment_request
)
quote = await blink.get_payment_quote(melt_quote_request)
assert quote.checking_id == payment_request assert quote.checking_id == payment_request
assert quote.amount == Amount(Unit.sat, 1000) # sat assert quote.amount == Amount(Unit.sat, 1000) # sat
assert quote.fee == Amount(Unit.sat, 5) # sat assert quote.fee == Amount(Unit.sat, 5) # sat
@@ -200,7 +203,10 @@ async def test_blink_get_payment_quote():
# response says 10 sat fees but invoice (1000 sat) * 0.5% is 5 sat so we expect 10 sat # response says 10 sat fees but invoice (1000 sat) * 0.5% is 5 sat so we expect 10 sat
mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}} mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
quote = await blink.get_payment_quote(payment_request) melt_quote_request = PostMeltQuoteRequest(
unit=Unit.sat.name, request=payment_request
)
quote = await blink.get_payment_quote(melt_quote_request)
assert quote.checking_id == payment_request assert quote.checking_id == payment_request
assert quote.amount == Amount(Unit.sat, 1000) # sat assert quote.amount == Amount(Unit.sat, 1000) # sat
assert quote.fee == Amount(Unit.sat, 10) # sat assert quote.fee == Amount(Unit.sat, 10) # sat
@@ -208,7 +214,10 @@ async def test_blink_get_payment_quote():
# response says 10 sat fees but invoice (4973 sat) * 0.5% is 24.865 sat so we expect 25 sat # response says 10 sat fees but invoice (4973 sat) * 0.5% is 24.865 sat so we expect 25 sat
mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}} mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
quote = await blink.get_payment_quote(payment_request_4973) melt_quote_request_4973 = PostMeltQuoteRequest(
unit=Unit.sat.name, request=payment_request_4973
)
quote = await blink.get_payment_quote(melt_quote_request_4973)
assert quote.checking_id == payment_request_4973 assert quote.checking_id == payment_request_4973
assert quote.amount == Amount(Unit.sat, 4973) # sat assert quote.amount == Amount(Unit.sat, 4973) # sat
assert quote.fee == Amount(Unit.sat, 25) # sat assert quote.fee == Amount(Unit.sat, 25) # sat
@@ -216,7 +225,10 @@ async def test_blink_get_payment_quote():
# response says 0 sat fees but invoice (1 sat) * 0.5% is 0.005 sat so we expect MINIMUM_FEE_MSAT/1000 sat # response says 0 sat fees but invoice (1 sat) * 0.5% is 0.005 sat so we expect MINIMUM_FEE_MSAT/1000 sat
mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 0}}} mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 0}}}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
quote = await blink.get_payment_quote(payment_request_1) melt_quote_request_1 = PostMeltQuoteRequest(
unit=Unit.sat.name, request=payment_request_1
)
quote = await blink.get_payment_quote(melt_quote_request_1)
assert quote.checking_id == payment_request_1 assert quote.checking_id == payment_request_1
assert quote.amount == Amount(Unit.sat, 1) # sat assert quote.amount == Amount(Unit.sat, 1) # sat
assert quote.fee == Amount(Unit.sat, MINIMUM_FEE_MSAT // 1000) # msat assert quote.fee == Amount(Unit.sat, MINIMUM_FEE_MSAT // 1000) # msat
@@ -228,7 +240,10 @@ async def test_blink_get_payment_quote_backend_error():
# response says error but invoice (1000 sat) * 0.5% is 5 sat so we expect 10 sat # response says error but invoice (1000 sat) * 0.5% is 5 sat so we expect 10 sat
mock_response = {"data": {"lnInvoiceFeeProbe": {"errors": [{"message": "error"}]}}} mock_response = {"data": {"lnInvoiceFeeProbe": {"errors": [{"message": "error"}]}}}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
quote = await blink.get_payment_quote(payment_request) melt_quote_request = PostMeltQuoteRequest(
unit=Unit.sat.name, request=payment_request
)
quote = await blink.get_payment_quote(melt_quote_request)
assert quote.checking_id == payment_request assert quote.checking_id == payment_request
assert quote.amount == Amount(Unit.sat, 1000) # sat assert quote.amount == Amount(Unit.sat, 1000) # sat
assert quote.fee == Amount(Unit.sat, 5) # sat assert quote.fee == Amount(Unit.sat, 5) # sat

View File

@@ -73,7 +73,7 @@ async def test_melt_external(wallet1: Wallet, ledger: Ledger):
invoice_dict = get_real_invoice(64) invoice_dict = get_real_invoice(64)
invoice_payment_request = invoice_dict["payment_request"] invoice_payment_request = invoice_dict["payment_request"]
mint_quote = await wallet1.get_pay_amount_with_fees(invoice_payment_request) mint_quote = await wallet1.melt_quote(invoice_payment_request)
total_amount = mint_quote.amount + mint_quote.fee_reserve total_amount = mint_quote.amount + mint_quote.fee_reserve
keep_proofs, send_proofs = await wallet1.split_to_send(wallet1.proofs, total_amount) keep_proofs, send_proofs = await wallet1.split_to_send(wallet1.proofs, total_amount)
melt_quote = await ledger.melt_quote( melt_quote = await ledger.melt_quote(

View File

@@ -41,12 +41,12 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger):
invoice_payment_request = str(invoice_dict["payment_request"]) invoice_payment_request = str(invoice_dict["payment_request"])
# wallet pays the invoice # wallet pays the invoice
quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) quote = await wallet.melt_quote(invoice_payment_request)
total_amount = quote.amount + quote.fee_reserve total_amount = quote.amount + quote.fee_reserve
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
asyncio.create_task(ledger.melt(proofs=send_proofs, quote=quote.quote)) asyncio.create_task(ledger.melt(proofs=send_proofs, quote=quote.quote))
# asyncio.create_task( # asyncio.create_task(
# wallet.pay_lightning( # wallet.melt(
# proofs=send_proofs, # proofs=send_proofs,
# invoice=invoice_payment_request, # invoice=invoice_payment_request,
# fee_reserve_sat=quote.fee_reserve, # fee_reserve_sat=quote.fee_reserve,

View File

@@ -271,7 +271,7 @@ async def test_melt(wallet1: Wallet):
invoice_payment_hash = str(invoice.payment_hash) invoice_payment_hash = str(invoice.payment_hash)
invoice_payment_request = invoice.bolt11 invoice_payment_request = invoice.bolt11
quote = await wallet1.get_pay_amount_with_fees(invoice_payment_request) quote = await wallet1.request_melt(invoice_payment_request)
total_amount = quote.amount + quote.fee_reserve total_amount = quote.amount + quote.fee_reserve
if is_regtest: if is_regtest:
@@ -285,7 +285,7 @@ async def test_melt(wallet1: Wallet):
_, send_proofs = await wallet1.split_to_send(wallet1.proofs, total_amount) _, send_proofs = await wallet1.split_to_send(wallet1.proofs, total_amount)
melt_response = await wallet1.pay_lightning( melt_response = await wallet1.melt(
proofs=send_proofs, proofs=send_proofs,
invoice=invoice_payment_request, invoice=invoice_payment_request,
fee_reserve_sat=quote.fee_reserve, fee_reserve_sat=quote.fee_reserve,

View File

@@ -43,11 +43,11 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger):
invoice_payment_request = str(invoice_dict["payment_request"]) invoice_payment_request = str(invoice_dict["payment_request"])
# wallet pays the invoice # wallet pays the invoice
quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) quote = await wallet.melt_quote(invoice_payment_request)
total_amount = quote.amount + quote.fee_reserve total_amount = quote.amount + quote.fee_reserve
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
asyncio.create_task( asyncio.create_task(
wallet.pay_lightning( wallet.melt(
proofs=send_proofs, proofs=send_proofs,
invoice=invoice_payment_request, invoice=invoice_payment_request,
fee_reserve_sat=quote.fee_reserve, fee_reserve_sat=quote.fee_reserve,
@@ -83,11 +83,11 @@ async def test_regtest_failed_quote(wallet: Wallet, ledger: Ledger):
preimage_hash = invoice_obj.payment_hash preimage_hash = invoice_obj.payment_hash
# wallet pays the invoice # wallet pays the invoice
quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) quote = await wallet.melt_quote(invoice_payment_request)
total_amount = quote.amount + quote.fee_reserve total_amount = quote.amount + quote.fee_reserve
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
asyncio.create_task( asyncio.create_task(
wallet.pay_lightning( wallet.melt(
proofs=send_proofs, proofs=send_proofs,
invoice=invoice_payment_request, invoice=invoice_payment_request,
fee_reserve_sat=quote.fee_reserve, fee_reserve_sat=quote.fee_reserve,

View File

@@ -0,0 +1,127 @@
import asyncio
from typing import List
import pytest
import pytest_asyncio
from cashu.core.base import Method, Proof
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import (
get_real_invoice,
is_fake,
pay_if_regtest,
)
@pytest_asyncio.fixture(scope="function")
async def wallet():
wallet = await Wallet.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet",
name="wallet",
)
await wallet.load_mint()
yield wallet
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_pay_mpp(wallet: Wallet, ledger: Ledger):
# make sure that mpp is supported by the bolt11-sat backend
if not ledger.backends[Method["bolt11"]][wallet.unit].supports_mpp:
pytest.skip("backend does not support mpp")
# make sure wallet knows the backend supports mpp
assert wallet.mint_info.supports_mpp("bolt11", wallet.unit)
# top up wallet twice so we have enough for two payments
topup_invoice = await wallet.request_mint(128)
pay_if_regtest(topup_invoice.bolt11)
proofs1 = await wallet.mint(128, id=topup_invoice.id)
assert wallet.balance == 128
topup_invoice = await wallet.request_mint(128)
pay_if_regtest(topup_invoice.bolt11)
proofs2 = await wallet.mint(128, id=topup_invoice.id)
assert wallet.balance == 256
# this is the invoice we want to pay in two parts
invoice_dict = get_real_invoice(64)
invoice_payment_request = invoice_dict["payment_request"]
async def pay_mpp(amount: int, proofs: List[Proof], delay: float = 0.0):
await asyncio.sleep(delay)
# wallet pays 32 sat of the invoice
quote = await wallet.melt_quote(invoice_payment_request, amount=32)
assert quote.amount == amount
await wallet.melt(
proofs,
invoice_payment_request,
fee_reserve_sat=quote.fee_reserve,
quote_id=quote.quote,
)
# call pay_mpp twice in parallel to pay the full invoice
# we delay the second payment so that the wallet doesn't derive the same blindedmessages twice due to a race condition
await asyncio.gather(pay_mpp(32, proofs1), pay_mpp(32, proofs2, delay=0.5))
assert wallet.balance <= 256 - 64
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_pay_mpp_incomplete_payment(wallet: Wallet, ledger: Ledger):
# make sure that mpp is supported by the bolt11-sat backend
if not ledger.backends[Method["bolt11"]][wallet.unit].supports_mpp:
pytest.skip("backend does not support mpp")
# make sure wallet knows the backend supports mpp
assert wallet.mint_info.supports_mpp("bolt11", wallet.unit)
# top up wallet twice so we have enough for three payments
topup_invoice = await wallet.request_mint(128)
pay_if_regtest(topup_invoice.bolt11)
proofs1 = await wallet.mint(128, id=topup_invoice.id)
assert wallet.balance == 128
topup_invoice = await wallet.request_mint(128)
pay_if_regtest(topup_invoice.bolt11)
proofs2 = await wallet.mint(128, id=topup_invoice.id)
assert wallet.balance == 256
topup_invoice = await wallet.request_mint(128)
pay_if_regtest(topup_invoice.bolt11)
proofs3 = await wallet.mint(128, id=topup_invoice.id)
assert wallet.balance == 384
# this is the invoice we want to pay in two parts
invoice_dict = get_real_invoice(64)
invoice_payment_request = invoice_dict["payment_request"]
async def pay_mpp(amount: int, proofs: List[Proof], delay: float = 0.0):
await asyncio.sleep(delay)
# wallet pays 32 sat of the invoice
quote = await wallet.melt_quote(invoice_payment_request, amount=amount)
assert quote.amount == amount
await wallet.melt(
proofs,
invoice_payment_request,
fee_reserve_sat=quote.fee_reserve,
quote_id=quote.quote,
)
# instead: call pay_mpp twice in the background, sleep for a bit, then check if the payment was successful (it should not be)
asyncio.create_task(pay_mpp(32, proofs1))
asyncio.create_task(pay_mpp(16, proofs2, delay=0.5))
await asyncio.sleep(2)
# payment is still pending because the full amount has not been paid
assert wallet.balance == 384
# send the remaining 16 sat to complete the payment
asyncio.create_task(pay_mpp(16, proofs3, delay=0.5))
await asyncio.sleep(2)
assert wallet.balance <= 384 - 64