mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-20 10:34:20 +01:00
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:
1
.github/workflows/regtest.yml
vendored
1
.github/workflows/regtest.yml
vendored
@@ -65,6 +65,7 @@ jobs:
|
||||
MINT_LND_REST_ENDPOINT: https://localhost:8081/
|
||||
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_ENABLE_MPP: true
|
||||
# LND_GRPC_ENDPOINT: localhost
|
||||
# LND_GRPC_PORT: 10009
|
||||
# LND_GRPC_CERT: ./regtest/data/lnd-3/tls.cert
|
||||
|
||||
@@ -161,13 +161,18 @@ class Proof(BaseModel):
|
||||
return HTLCWitness.from_witness(self.witness).preimage
|
||||
|
||||
|
||||
class Proofs(BaseModel):
|
||||
# NOTE: not used in Pydantic validation
|
||||
__root__: List[Proof]
|
||||
|
||||
|
||||
class BlindedMessage(BaseModel):
|
||||
"""
|
||||
Blinded message or blinded secret or "output" which is to be signed by the mint
|
||||
"""
|
||||
|
||||
amount: int
|
||||
id: str
|
||||
id: str # Keyset id
|
||||
B_: str # Hex-encoded blinded message
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Blinded signature or "promise" which is the signature on a `BlindedMessage`
|
||||
@@ -314,7 +341,13 @@ class GetInfoResponse(BaseModel):
|
||||
description_long: Optional[str] = None
|
||||
contact: Optional[List[List[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):
|
||||
@@ -329,19 +362,6 @@ class GetInfoResponse_deprecated(BaseModel):
|
||||
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 -------
|
||||
|
||||
|
||||
@@ -425,6 +445,7 @@ class PostMeltQuoteRequest(BaseModel):
|
||||
request: str = Field(
|
||||
..., max_length=settings.mint_max_request_length
|
||||
) # output payment request
|
||||
amount: Optional[int] = Field(default=None, gt=0) # input amount
|
||||
|
||||
|
||||
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):
|
||||
outputs: List[BlindedMessage] = []
|
||||
signatures: List[BlindedSignature] = []
|
||||
@@ -656,6 +683,7 @@ class WalletKeyset:
|
||||
valid_to=None,
|
||||
first_seen=None,
|
||||
active=True,
|
||||
use_deprecated_id=False, # BACKWARDS COMPATIBILITY < 0.15.0
|
||||
):
|
||||
self.valid_from = valid_from
|
||||
self.valid_to = valid_to
|
||||
@@ -670,10 +698,19 @@ class WalletKeyset:
|
||||
else:
|
||||
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]
|
||||
|
||||
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(
|
||||
f"WARNING: Keyset id {self.id} does not match the given id {id}."
|
||||
" Overwriting."
|
||||
@@ -728,6 +765,8 @@ class MintKeyset:
|
||||
first_seen: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
|
||||
duplicate_keyset_id: Optional[str] = None # BACKWARDS COMPATIBILITY < 0.15.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -808,12 +847,6 @@ class MintKeyset:
|
||||
assert self.seed, "seed 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):
|
||||
# WARNING: Broken key derivation for backwards compatibility with < 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"
|
||||
" compatibility < 0.12)"
|
||||
)
|
||||
# load from db or derive
|
||||
self.id = id_in_db or derive_keyset_id_deprecated(self.public_keys) # type: ignore
|
||||
self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore
|
||||
elif self.version_tuple < (0, 15):
|
||||
self.private_keys = derive_keys_sha256(self.seed, self.derivation_path)
|
||||
logger.trace(
|
||||
@@ -833,13 +865,11 @@ class MintKeyset:
|
||||
" compatibility < 0.15)"
|
||||
)
|
||||
self.public_keys = derive_pubkeys(self.private_keys) # type: ignore
|
||||
# load from db or derive
|
||||
self.id = id_in_db or derive_keyset_id_deprecated(self.public_keys) # type: ignore
|
||||
self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore
|
||||
else:
|
||||
self.private_keys = derive_keys(self.seed, self.derivation_path)
|
||||
self.public_keys = derive_pubkeys(self.private_keys) # type: ignore
|
||||
# load from db or derive
|
||||
self.id = id_in_db or derive_keyset_id(self.public_keys) # type: ignore
|
||||
self.id = derive_keyset_id(self.public_keys) # type: ignore
|
||||
|
||||
|
||||
# ------- TOKEN -------
|
||||
|
||||
@@ -89,6 +89,7 @@ class MintLimits(MintSettings):
|
||||
)
|
||||
mint_max_request_length: int = Field(
|
||||
default=1000,
|
||||
gt=0,
|
||||
title="Maximum request length",
|
||||
description="Maximum length of REST API request arrays.",
|
||||
)
|
||||
@@ -100,16 +101,21 @@ class MintLimits(MintSettings):
|
||||
)
|
||||
mint_max_peg_in: int = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
title="Maximum peg-in",
|
||||
description="Maximum amount for a mint operation.",
|
||||
)
|
||||
mint_max_peg_out: int = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
title="Maximum peg-out",
|
||||
description="Maximum amount for a melt operation.",
|
||||
)
|
||||
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_admin_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):
|
||||
|
||||
@@ -3,7 +3,12 @@ from typing import Coroutine, Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.base import Amount, MeltQuote, Unit
|
||||
from ..core.base import (
|
||||
Amount,
|
||||
MeltQuote,
|
||||
PostMeltQuoteRequest,
|
||||
Unit,
|
||||
)
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
@@ -62,6 +67,7 @@ class PaymentStatus(BaseModel):
|
||||
|
||||
|
||||
class LightningBackend(ABC):
|
||||
supports_mpp: bool = False
|
||||
supported_units: set[Unit]
|
||||
unit: Unit
|
||||
|
||||
@@ -107,7 +113,7 @@ class LightningBackend(ABC):
|
||||
@abstractmethod
|
||||
async def get_payment_quote(
|
||||
self,
|
||||
bolt11: str,
|
||||
melt_quote: PostMeltQuoteRequest,
|
||||
) -> PaymentQuoteResponse:
|
||||
pass
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from bolt11 import (
|
||||
)
|
||||
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 .base import (
|
||||
InvoiceResponse,
|
||||
@@ -375,7 +375,10 @@ class BlinkWallet(LightningBackend):
|
||||
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 = {
|
||||
"input": {
|
||||
"paymentRequest": bolt11,
|
||||
|
||||
@@ -10,7 +10,7 @@ from bolt11 import (
|
||||
)
|
||||
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.settings import settings
|
||||
from .base import (
|
||||
@@ -316,8 +316,10 @@ class CoreLightningRestWallet(LightningBackend):
|
||||
)
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse:
|
||||
invoice_obj = decode(bolt11)
|
||||
async def get_payment_quote(
|
||||
self, melt_quote: PostMeltQuoteRequest
|
||||
) -> PaymentQuoteResponse:
|
||||
invoice_obj = decode(melt_quote.request)
|
||||
assert invoice_obj.amount_msat, "invoice has no amount."
|
||||
amount_msat = int(invoice_obj.amount_msat)
|
||||
fees_msat = fee_reserve(amount_msat)
|
||||
|
||||
@@ -15,7 +15,7 @@ from bolt11 import (
|
||||
encode,
|
||||
)
|
||||
|
||||
from ..core.base import Amount, MeltQuote, Unit
|
||||
from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit
|
||||
from ..core.helpers import fee_reserve
|
||||
from ..core.settings import settings
|
||||
from .base import (
|
||||
@@ -152,8 +152,10 @@ class FakeWallet(LightningBackend):
|
||||
# amount = invoice_obj.amount_msat
|
||||
# return InvoiceQuoteResponse(checking_id="", amount=amount)
|
||||
|
||||
async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse:
|
||||
invoice_obj = decode(bolt11)
|
||||
async def get_payment_quote(
|
||||
self, melt_quote: PostMeltQuoteRequest
|
||||
) -> PaymentQuoteResponse:
|
||||
invoice_obj = decode(melt_quote.request)
|
||||
assert invoice_obj.amount_msat, "invoice has no amount."
|
||||
|
||||
if self.unit == Unit.sat:
|
||||
|
||||
@@ -6,7 +6,7 @@ from bolt11 import (
|
||||
decode,
|
||||
)
|
||||
|
||||
from ..core.base import Amount, MeltQuote, Unit
|
||||
from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit
|
||||
from ..core.helpers import fee_reserve
|
||||
from ..core.settings import settings
|
||||
from .base import (
|
||||
@@ -167,8 +167,10 @@ class LNbitsWallet(LightningBackend):
|
||||
preimage=data["preimage"],
|
||||
)
|
||||
|
||||
async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse:
|
||||
invoice_obj = decode(bolt11)
|
||||
async def get_payment_quote(
|
||||
self, melt_quote: PostMeltQuoteRequest
|
||||
) -> PaymentQuoteResponse:
|
||||
invoice_obj = decode(melt_quote.request)
|
||||
assert invoice_obj.amount_msat, "invoice has no amount."
|
||||
amount_msat = int(invoice_obj.amount_msat)
|
||||
fees_msat = fee_reserve(amount_msat)
|
||||
|
||||
@@ -4,13 +4,15 @@ import hashlib
|
||||
import json
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
import bolt11
|
||||
import httpx
|
||||
from bolt11 import (
|
||||
TagChar,
|
||||
decode,
|
||||
)
|
||||
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.settings import settings
|
||||
from .base import (
|
||||
@@ -27,6 +29,7 @@ from .macaroon import load_macaroon
|
||||
class LndRestWallet(LightningBackend):
|
||||
"""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])
|
||||
unit = Unit.sat
|
||||
|
||||
@@ -70,6 +73,8 @@ class LndRestWallet(LightningBackend):
|
||||
self.client = httpx.AsyncClient(
|
||||
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:
|
||||
try:
|
||||
@@ -148,6 +153,16 @@ class LndRestWallet(LightningBackend):
|
||||
async def pay_invoice(
|
||||
self, quote: MeltQuote, fee_limit_msat: int
|
||||
) -> 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
|
||||
lnrpcFeeLimit = dict()
|
||||
lnrpcFeeLimit["fixed_msat"] = f"{fee_limit_msat}"
|
||||
@@ -180,6 +195,91 @@ class LndRestWallet(LightningBackend):
|
||||
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:
|
||||
r = await self.client.get(url=f"/v1/invoice/{checking_id}")
|
||||
|
||||
@@ -270,13 +370,29 @@ class LndRestWallet(LightningBackend):
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse:
|
||||
invoice_obj = decode(bolt11)
|
||||
async def get_payment_quote(
|
||||
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."
|
||||
amount_msat = int(invoice_obj.amount_msat)
|
||||
|
||||
if amount:
|
||||
amount_msat = amount.to(Unit.msat).amount
|
||||
else:
|
||||
amount_msat = int(invoice_obj.amount_msat)
|
||||
|
||||
fees_msat = fee_reserve(amount_msat)
|
||||
fees = Amount(unit=Unit.msat, amount=fees_msat)
|
||||
|
||||
amount = Amount(unit=Unit.msat, amount=amount_msat)
|
||||
|
||||
return PaymentQuoteResponse(
|
||||
checking_id=invoice_obj.payment_hash,
|
||||
fee=fees.to(self.unit, round="up"),
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from ..core.base import Amount, MeltQuote, Unit
|
||||
from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit
|
||||
from ..core.settings import settings
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
@@ -118,7 +118,10 @@ class StrikeUSDWallet(LightningBackend):
|
||||
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:
|
||||
r = await self.client.post(
|
||||
url=f"{self.endpoint}/v1/payment-quotes/lightning",
|
||||
|
||||
@@ -575,9 +575,10 @@ class Ledger(LedgerVerification, LedgerSpendingConditions):
|
||||
)
|
||||
else:
|
||||
# not internal, get payment quote by backend
|
||||
payment_quote = await self.backends[method][unit].get_payment_quote(request)
|
||||
if not payment_quote.checking_id:
|
||||
raise TransactionError("quote has no checking id")
|
||||
payment_quote = await self.backends[method][unit].get_payment_quote(
|
||||
melt_quote=melt_quote
|
||||
)
|
||||
assert payment_quote.checking_id, "quote has no checking id"
|
||||
# make sure the backend returned the amount with a correct unit
|
||||
if not payment_quote.amount.unit == unit:
|
||||
raise TransactionError("payment quote amount units do not match")
|
||||
|
||||
@@ -62,7 +62,8 @@ async def info() -> GetInfoResponse:
|
||||
|
||||
supported_dict = dict(supported=True)
|
||||
|
||||
mint_features: Dict[int, Dict[str, Any]] = {
|
||||
supported_dict = dict(supported=True)
|
||||
mint_features: Dict[int, Any] = {
|
||||
4: dict(
|
||||
methods=method_settings[4],
|
||||
disabled=settings.mint_peg_out_only,
|
||||
@@ -79,6 +80,21 @@ async def info() -> GetInfoResponse:
|
||||
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(
|
||||
name=settings.mint_info_name,
|
||||
pubkey=ledger.pubkey.serialize().hex() if ledger.pubkey else None,
|
||||
|
||||
@@ -20,7 +20,7 @@ from ..core.base import (
|
||||
PostMintQuoteRequest,
|
||||
PostMintRequest_deprecated,
|
||||
PostMintResponse_deprecated,
|
||||
PostRestoreRequest,
|
||||
PostRestoreRequest_Deprecated,
|
||||
PostRestoreResponse,
|
||||
PostSplitRequest_Deprecated,
|
||||
PostSplitResponse_Deprecated,
|
||||
@@ -179,7 +179,7 @@ async def mint_deprecated(
|
||||
# BEGIN BACKWARDS COMPATIBILITY < 0.15
|
||||
# Mint expects "id" in outputs to know which keyset to use to sign them.
|
||||
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
|
||||
]
|
||||
# END BACKWARDS COMPATIBILITY < 0.15
|
||||
@@ -223,7 +223,7 @@ async def melt_deprecated(
|
||||
# BEGIN BACKWARDS COMPATIBILITY < 0.14: add "id" to outputs
|
||||
if payload.outputs:
|
||||
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
|
||||
]
|
||||
else:
|
||||
@@ -295,7 +295,7 @@ async def split_deprecated(
|
||||
assert payload.outputs, Exception("no outputs provided.")
|
||||
# BEGIN BACKWARDS COMPATIBILITY < 0.14: add "id" to outputs
|
||||
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
|
||||
]
|
||||
# END BACKWARDS COMPATIBILITY < 0.14
|
||||
@@ -372,7 +372,15 @@ async def check_spendable_deprecated(
|
||||
),
|
||||
deprecated=True,
|
||||
)
|
||||
async def restore(payload: PostRestoreRequest) -> PostRestoreResponse:
|
||||
async def restore(payload: PostRestoreRequest_Deprecated) -> PostRestoreResponse:
|
||||
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)
|
||||
|
||||
@@ -189,7 +189,7 @@ async def swap(
|
||||
|
||||
# pay invoice from outgoing mint
|
||||
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
|
||||
if outgoing_wallet.available_balance < total_amount:
|
||||
raise Exception("balance too low")
|
||||
@@ -197,7 +197,7 @@ async def swap(
|
||||
_, send_proofs = await outgoing_wallet.split_to_send(
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@@ -177,16 +177,23 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool):
|
||||
|
||||
@cli.command("pay", help="Pay Lightning invoice.")
|
||||
@click.argument("invoice", type=str)
|
||||
@click.argument(
|
||||
"amount",
|
||||
type=int,
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
"--yes", "-y", default=False, is_flag=True, help="Skip confirmation.", type=bool
|
||||
)
|
||||
@click.pass_context
|
||||
@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"]
|
||||
await wallet.load_mint()
|
||||
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}")
|
||||
total_amount = quote.amount + quote.fee_reserve
|
||||
if not yes:
|
||||
@@ -209,7 +216,7 @@ async def pay(ctx: Context, invoice: str, yes: bool):
|
||||
return
|
||||
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
|
||||
try:
|
||||
melt_response = await wallet.pay_lightning(
|
||||
melt_response = await wallet.melt(
|
||||
send_proofs, invoice, quote.fee_reserve, quote.quote
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -334,14 +341,14 @@ async def swap(ctx: Context):
|
||||
invoice = await incoming_wallet.request_mint(amount)
|
||||
|
||||
# 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
|
||||
if outgoing_wallet.available_balance < total_amount:
|
||||
raise Exception("balance too low")
|
||||
_, send_proofs = await outgoing_wallet.split_to_send(
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ class LightningWallet(Wallet):
|
||||
Returns:
|
||||
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
|
||||
assert total_amount > 0, "amount is not positive"
|
||||
if self.available_balance < total_amount:
|
||||
@@ -63,9 +63,7 @@ class LightningWallet(Wallet):
|
||||
return PaymentResponse(ok=False)
|
||||
_, send_proofs = await self.split_to_send(self.proofs, total_amount)
|
||||
try:
|
||||
resp = await self.pay_lightning(
|
||||
send_proofs, pr, quote.fee_reserve, quote.quote
|
||||
)
|
||||
resp = await self.melt(send_proofs, pr, quote.fee_reserve, quote.quote)
|
||||
if resp.change:
|
||||
fees_paid_sat = quote.fee_reserve - sum_promises(resp.change)
|
||||
else:
|
||||
|
||||
38
cashu/wallet/mint_info.py
Normal file
38
cashu/wallet/mint_info.py
Normal 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
|
||||
@@ -71,6 +71,7 @@ from ..wallet.crud import (
|
||||
)
|
||||
from . import migrations
|
||||
from .htlc import WalletHTLC
|
||||
from .mint_info import MintInfo
|
||||
from .p2pk import WalletP2PK
|
||||
from .secrets import WalletSecrets
|
||||
from .wallet_deprecated import LedgerAPIDeprecated
|
||||
@@ -130,7 +131,7 @@ class LedgerAPI(LedgerAPIDeprecated, object):
|
||||
keysets: Dict[str, WalletKeyset] # holds keysets
|
||||
mint_keyset_ids: List[str] # holds active keyset ids of the mint
|
||||
unit: Unit
|
||||
mint_info: GetInfoResponse # holds info about mint
|
||||
mint_info: MintInfo # holds info about mint
|
||||
tor: TorProxy
|
||||
db: Database
|
||||
httpx: httpx.AsyncClient
|
||||
@@ -269,9 +270,10 @@ class LedgerAPI(LedgerAPIDeprecated, object):
|
||||
logger.debug(f"Mint keysets: {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."""
|
||||
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}")
|
||||
return self.mint_info
|
||||
|
||||
@@ -518,11 +520,15 @@ class LedgerAPI(LedgerAPIDeprecated, object):
|
||||
|
||||
@async_set_httpx_client
|
||||
@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."""
|
||||
invoice_obj = bolt11.decode(payment_request)
|
||||
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(
|
||||
join(self.url, "/v1/melt/quote/bolt11"),
|
||||
json=payload.dict(),
|
||||
@@ -536,7 +542,7 @@ class LedgerAPI(LedgerAPIDeprecated, object):
|
||||
quote_id = "deprecated_" + str(uuid.uuid4())
|
||||
return PostMeltQuoteResponse(
|
||||
quote=quote_id,
|
||||
amount=invoice_obj.amount_msat // 1000,
|
||||
amount=amount or invoice_obj.amount_msat // 1000,
|
||||
fee_reserve=ret.fee or 0,
|
||||
paid=False,
|
||||
expiry=invoice_obj.expiry,
|
||||
@@ -582,7 +588,7 @@ class LedgerAPI(LedgerAPIDeprecated, object):
|
||||
if resp.status_code == 404:
|
||||
invoice = await get_lightning_invoice(id=quote, db=self.db)
|
||||
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
|
||||
)
|
||||
return PostMeltResponse(
|
||||
@@ -987,7 +993,21 @@ class Wallet(LedgerAPI, WalletP2PK, WalletHTLC, WalletSecrets):
|
||||
send_proofs = new_proofs[len(frst_outputs) :]
|
||||
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
|
||||
) -> PostMeltResponse:
|
||||
"""Pays a lightning invoice and returns the status of the payment.
|
||||
@@ -1520,17 +1540,6 @@ class Wallet(LedgerAPI, WalletP2PK, WalletHTLC, WalletSecrets):
|
||||
|
||||
# ---------- 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(
|
||||
self,
|
||||
proofs: List[Proof],
|
||||
|
||||
@@ -298,7 +298,7 @@ class LedgerAPIDeprecated(SupportsHttpxClient, SupportsMintURL):
|
||||
|
||||
@async_set_httpx_client
|
||||
@async_ensure_mint_loaded_deprecated
|
||||
async def pay_lightning_deprecated(
|
||||
async def melt_deprecated(
|
||||
self, proofs: List[Proof], invoice: str, outputs: Optional[List[BlindedMessage]]
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -44,6 +44,7 @@ settings.mint_derivation_path_list = []
|
||||
settings.mint_private_key = "TEST_PRIVATE_KEY"
|
||||
settings.mint_seed_decryption_key = ""
|
||||
settings.mint_max_balance = 0
|
||||
settings.mint_lnd_enable_mpp = True
|
||||
|
||||
assert "test" in settings.cashu_dir
|
||||
shutil.rmtree(settings.cashu_dir, ignore_errors=True)
|
||||
|
||||
@@ -249,11 +249,11 @@ async def test_startup_regtest_pending_quote_pending(wallet: Wallet, ledger: Led
|
||||
invoice_payment_request = str(invoice_dict["payment_request"])
|
||||
|
||||
# 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
|
||||
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
|
||||
asyncio.create_task(
|
||||
wallet.pay_lightning(
|
||||
wallet.melt(
|
||||
proofs=send_proofs,
|
||||
invoice=invoice_payment_request,
|
||||
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"])
|
||||
|
||||
# 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
|
||||
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
|
||||
asyncio.create_task(
|
||||
wallet.pay_lightning(
|
||||
wallet.melt(
|
||||
proofs=send_proofs,
|
||||
invoice=invoice_payment_request,
|
||||
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
|
||||
|
||||
# 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
|
||||
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
|
||||
asyncio.create_task(
|
||||
wallet.pay_lightning(
|
||||
wallet.melt(
|
||||
proofs=send_proofs,
|
||||
invoice=invoice_payment_request,
|
||||
fee_reserve_sat=quote.fee_reserve,
|
||||
|
||||
@@ -2,7 +2,7 @@ import pytest
|
||||
import respx
|
||||
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.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
|
||||
mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 1}}}
|
||||
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.amount == Amount(Unit.sat, 1000) # 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
|
||||
mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}}
|
||||
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.amount == Amount(Unit.sat, 1000) # 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
|
||||
mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}}
|
||||
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.amount == Amount(Unit.sat, 4973) # 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
|
||||
mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 0}}}
|
||||
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.amount == Amount(Unit.sat, 1) # sat
|
||||
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
|
||||
mock_response = {"data": {"lnInvoiceFeeProbe": {"errors": [{"message": "error"}]}}}
|
||||
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.amount == Amount(Unit.sat, 1000) # sat
|
||||
assert quote.fee == Amount(Unit.sat, 5) # sat
|
||||
|
||||
@@ -73,7 +73,7 @@ async def test_melt_external(wallet1: Wallet, ledger: Ledger):
|
||||
invoice_dict = get_real_invoice(64)
|
||||
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
|
||||
keep_proofs, send_proofs = await wallet1.split_to_send(wallet1.proofs, total_amount)
|
||||
melt_quote = await ledger.melt_quote(
|
||||
|
||||
@@ -41,12 +41,12 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger):
|
||||
invoice_payment_request = str(invoice_dict["payment_request"])
|
||||
|
||||
# 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
|
||||
_, 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(
|
||||
# wallet.pay_lightning(
|
||||
# wallet.melt(
|
||||
# proofs=send_proofs,
|
||||
# invoice=invoice_payment_request,
|
||||
# fee_reserve_sat=quote.fee_reserve,
|
||||
|
||||
@@ -271,7 +271,7 @@ async def test_melt(wallet1: Wallet):
|
||||
invoice_payment_hash = str(invoice.payment_hash)
|
||||
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
|
||||
|
||||
if is_regtest:
|
||||
@@ -285,7 +285,7 @@ async def test_melt(wallet1: Wallet):
|
||||
|
||||
_, 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,
|
||||
invoice=invoice_payment_request,
|
||||
fee_reserve_sat=quote.fee_reserve,
|
||||
|
||||
@@ -43,11 +43,11 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger):
|
||||
invoice_payment_request = str(invoice_dict["payment_request"])
|
||||
|
||||
# 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
|
||||
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
|
||||
asyncio.create_task(
|
||||
wallet.pay_lightning(
|
||||
wallet.melt(
|
||||
proofs=send_proofs,
|
||||
invoice=invoice_payment_request,
|
||||
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
|
||||
|
||||
# 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
|
||||
_, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount)
|
||||
asyncio.create_task(
|
||||
wallet.pay_lightning(
|
||||
wallet.melt(
|
||||
proofs=send_proofs,
|
||||
invoice=invoice_payment_request,
|
||||
fee_reserve_sat=quote.fee_reserve,
|
||||
|
||||
127
tests/test_wallet_regtest_mpp.py
Normal file
127
tests/test_wallet_regtest_mpp.py
Normal 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
|
||||
Reference in New Issue
Block a user