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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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
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 .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],

View File

@@ -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]]
):
"""

View File

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

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"])
# 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,

View File

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

View File

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

View File

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

View File

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

View File

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

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