WIP: New melt flow (#622)

* `PaymentResult`

* ledger: rely on PaymentResult instead of paid flag. Double check for payments marked pending.

* `None` is `PENDING`

* make format

* reflected changes API tests where `PaymentStatus` is used + reflected changes in lnbits

* reflect changes in blink backend and tests

* fix lnbits get_payment_status

* remove paid flag

* fix mypy

* remove more paid flags

* fix strike mypy

* green

* shorten all state checks

* fix

* fix some tests

* gimme 

* fix............

* fix lnbits

* fix error

* lightning refactor

* add more regtest tests

* add tests for pending state and failure

* shorten checks

* use match case for startup check - and remember modified checking_id from pay_invoice

* fix strike pending return

* new tests?

* refactor startup routine into get_melt_quote

* test with purge

* refactor blink

* cleanup responses

* blink: return checking_id on failure

* fix lndgrpc try except

* add more testing for melt branches

* speed things up a bit

* remove comments

* remove comments

* block pending melt quotes

* remove comments

---------

Co-authored-by: lollerfirst <lollerfirst@gmail.com>
This commit is contained in:
callebtc
2024-09-24 14:55:35 +02:00
committed by GitHub
parent 25f0763f94
commit d8d3037cc5
39 changed files with 1575 additions and 682 deletions

View File

@@ -80,6 +80,18 @@ class ProofState(LedgerEvent):
def kind(self) -> JSONRPCSubscriptionKinds:
return JSONRPCSubscriptionKinds.PROOF_STATE
@property
def unspent(self) -> bool:
return self.state == ProofSpentState.unspent
@property
def spent(self) -> bool:
return self.state == ProofSpentState.spent
@property
def pending(self) -> bool:
return self.state == ProofSpentState.pending
class HTLCWitness(BaseModel):
preimage: Optional[str] = None
@@ -290,7 +302,6 @@ class MeltQuote(LedgerEvent):
unit: str
amount: int
fee_reserve: int
paid: bool
state: MeltQuoteState
created_time: Union[int, None] = None
paid_time: Union[int, None] = None
@@ -325,7 +336,6 @@ class MeltQuote(LedgerEvent):
unit=row["unit"],
amount=row["amount"],
fee_reserve=row["fee_reserve"],
paid=row["paid"],
state=MeltQuoteState[row["state"]],
created_time=created_time,
paid_time=paid_time,
@@ -344,17 +354,34 @@ class MeltQuote(LedgerEvent):
def kind(self) -> JSONRPCSubscriptionKinds:
return JSONRPCSubscriptionKinds.BOLT11_MELT_QUOTE
@property
def unpaid(self) -> bool:
return self.state == MeltQuoteState.unpaid
@property
def pending(self) -> bool:
return self.state == MeltQuoteState.pending
@property
def paid(self) -> bool:
return self.state == MeltQuoteState.paid
# method that is invoked when the `state` attribute is changed. to protect the state from being set to anything else if the current state is paid
def __setattr__(self, name, value):
# an unpaid quote can only be set to pending or paid
if name == "state" and self.state == MeltQuoteState.unpaid:
if name == "state" and self.unpaid:
if value not in [MeltQuoteState.pending, MeltQuoteState.paid]:
raise Exception(
f"Cannot change state of an unpaid melt quote to {value}."
)
# a paid quote can not be changed
if name == "state" and self.state == MeltQuoteState.paid:
if name == "state" and self.paid:
raise Exception("Cannot change state of a paid melt quote.")
if name == "paid":
raise Exception(
"MeltQuote does not support `paid` anymore! Use `state` instead."
)
super().__setattr__(name, value)
@@ -375,8 +402,6 @@ class MintQuote(LedgerEvent):
checking_id: str
unit: str
amount: int
paid: bool
issued: bool
state: MintQuoteState
created_time: Union[int, None] = None
paid_time: Union[int, None] = None
@@ -401,8 +426,6 @@ class MintQuote(LedgerEvent):
checking_id=row["checking_id"],
unit=row["unit"],
amount=row["amount"],
paid=row["paid"],
issued=row["issued"],
state=MintQuoteState[row["state"]],
created_time=created_time,
paid_time=paid_time,
@@ -417,24 +440,45 @@ class MintQuote(LedgerEvent):
def kind(self) -> JSONRPCSubscriptionKinds:
return JSONRPCSubscriptionKinds.BOLT11_MINT_QUOTE
@property
def unpaid(self) -> bool:
return self.state == MintQuoteState.unpaid
@property
def paid(self) -> bool:
return self.state == MintQuoteState.paid
@property
def pending(self) -> bool:
return self.state == MintQuoteState.pending
@property
def issued(self) -> bool:
return self.state == MintQuoteState.issued
def __setattr__(self, name, value):
# un unpaid quote can only be set to paid
if name == "state" and self.state == MintQuoteState.unpaid:
if name == "state" and self.unpaid:
if value != MintQuoteState.paid:
raise Exception(
f"Cannot change state of an unpaid mint quote to {value}."
)
# a paid quote can only be set to pending or issued
if name == "state" and self.state == MintQuoteState.paid:
if name == "state" and self.paid:
if value != MintQuoteState.pending and value != MintQuoteState.issued:
raise Exception(f"Cannot change state of a paid mint quote to {value}.")
# a pending quote can only be set to paid or issued
if name == "state" and self.state == MintQuoteState.pending:
if name == "state" and self.pending:
if value not in [MintQuoteState.paid, MintQuoteState.issued]:
raise Exception("Cannot change state of a pending mint quote.")
# an issued quote cannot be changed
if name == "state" and self.state == MintQuoteState.issued:
if name == "state" and self.issued:
raise Exception("Cannot change state of an issued mint quote.")
if name == "paid":
raise Exception(
"MintQuote does not support `paid` anymore! Use `state` instead."
)
super().__setattr__(name, value)

View File

@@ -213,7 +213,7 @@ class PostMeltQuoteResponse(BaseModel):
fee_reserve: int # input fee reserve
paid: Optional[
bool
] # whether the request has been paid # DEPRECATED as per NUT PR #136
] = None # whether the request has been paid # DEPRECATED as per NUT PR #136
state: Optional[str] # state of the quote
expiry: Optional[int] # expiry of the quote
payment_preimage: Optional[str] = None # payment preimage
@@ -224,6 +224,8 @@ class PostMeltQuoteResponse(BaseModel):
to_dict = melt_quote.dict()
# turn state into string
to_dict["state"] = melt_quote.state.value
# add deprecated "paid" field
to_dict["paid"] = melt_quote.paid
return PostMeltQuoteResponse.parse_obj(to_dict)

View File

@@ -135,7 +135,8 @@ class FakeWalletSettings(MintSettings):
fakewallet_delay_outgoing_payment: Optional[float] = Field(default=3.0)
fakewallet_delay_incoming_payment: Optional[float] = Field(default=3.0)
fakewallet_stochastic_invoice: bool = Field(default=False)
fakewallet_payment_state: Optional[bool] = Field(default=None)
fakewallet_payment_state: Optional[str] = Field(default="SETTLED")
fakewallet_pay_invoice_state: Optional[str] = Field(default="SETTLED")
class MintInformation(CashuSettings):

View File

@@ -1,4 +1,5 @@
from abc import ABC, abstractmethod
from enum import Enum, auto
from typing import AsyncGenerator, Coroutine, Optional, Union
from pydantic import BaseModel
@@ -12,8 +13,8 @@ from ..core.models import PostMeltQuoteRequest
class StatusResponse(BaseModel):
error_message: Optional[str]
balance: Union[int, float]
error_message: Optional[str] = None
class InvoiceQuoteResponse(BaseModel):
@@ -34,36 +35,77 @@ class InvoiceResponse(BaseModel):
error_message: Optional[str] = None
class PaymentResult(Enum):
SETTLED = auto()
FAILED = auto()
PENDING = auto()
UNKNOWN = auto()
def __str__(self):
return self.name
class PaymentResponse(BaseModel):
ok: Optional[bool] = None # True: paid, False: failed, None: pending or unknown
result: PaymentResult
checking_id: Optional[str] = None
fee: Optional[Amount] = None
preimage: Optional[str] = None
error_message: Optional[str] = None
class PaymentStatus(BaseModel):
paid: Optional[bool] = None
fee: Optional[Amount] = None
preimage: Optional[str] = None
@property
def pending(self) -> bool:
return self.paid is not True
return self.result == PaymentResult.PENDING
@property
def settled(self) -> bool:
return self.result == PaymentResult.SETTLED
@property
def failed(self) -> bool:
return self.paid is False
return self.result == PaymentResult.FAILED
@property
def unknown(self) -> bool:
return self.result == PaymentResult.UNKNOWN
class PaymentStatus(BaseModel):
result: PaymentResult
fee: Optional[Amount] = None
preimage: Optional[str] = None
error_message: Optional[str] = None
@property
def pending(self) -> bool:
return self.result == PaymentResult.PENDING
@property
def settled(self) -> bool:
return self.result == PaymentResult.SETTLED
@property
def failed(self) -> bool:
return self.result == PaymentResult.FAILED
@property
def unknown(self) -> bool:
return self.result == PaymentResult.UNKNOWN
def __str__(self) -> str:
if self.paid is True:
return "settled"
elif self.paid is False:
if self.result == PaymentResult.SETTLED:
return (
"settled"
+ (f" (preimage: {self.preimage})" if self.preimage else "")
+ (f" (fee: {self.fee})" if self.fee else "")
)
elif self.result == PaymentResult.FAILED:
return "failed"
elif self.paid is None:
elif self.result == PaymentResult.PENDING:
return "still pending"
else:
return "unknown (should never happen)"
else: # self.result == PaymentResult.UNKNOWN:
return "unknown" + (
f" (Error: {self.error_message})" if self.error_message else ""
)
class LightningBackend(ABC):

View File

@@ -1,4 +1,3 @@
# type: ignore
import json
import math
from typing import AsyncGenerator, Dict, Optional, Union
@@ -18,6 +17,7 @@ from .base import (
LightningBackend,
PaymentQuoteResponse,
PaymentResponse,
PaymentResult,
PaymentStatus,
StatusResponse,
)
@@ -30,6 +30,22 @@ DIRECTION_RECEIVE = "RECEIVE"
PROBE_FEE_TIMEOUT_SEC = 1
MINIMUM_FEE_MSAT = 2000
INVOICE_RESULT_MAP = {
"PENDING": PaymentResult.PENDING,
"PAID": PaymentResult.SETTLED,
"EXPIRED": PaymentResult.FAILED,
}
PAYMENT_EXECUTION_RESULT_MAP = {
"SUCCESS": PaymentResult.SETTLED,
"ALREADY_PAID": PaymentResult.FAILED,
"FAILURE": PaymentResult.FAILED,
}
PAYMENT_RESULT_MAP = {
"SUCCESS": PaymentResult.SETTLED,
"PENDING": PaymentResult.PENDING,
"FAILURE": PaymentResult.FAILED,
}
class BlinkWallet(LightningBackend):
"""https://dev.blink.sv/
@@ -38,13 +54,6 @@ class BlinkWallet(LightningBackend):
wallet_ids: Dict[Unit, str] = {}
endpoint = "https://api.blink.sv/graphql"
invoice_statuses = {"PENDING": None, "PAID": True, "EXPIRED": False}
payment_execution_statuses = {
"SUCCESS": True,
"ALREADY_PAID": None,
"FAILURE": False,
}
payment_statuses = {"SUCCESS": True, "PENDING": None, "FAILURE": False}
supported_units = {Unit.sat, Unit.msat}
supports_description: bool = True
@@ -66,12 +75,13 @@ class BlinkWallet(LightningBackend):
async def status(self) -> StatusResponse:
try:
data = {
"query": "query me { me { defaultAccount { wallets { id walletCurrency balance }}}}",
"variables": {},
}
r = await self.client.post(
url=self.endpoint,
data=(
'{"query":"query me { me { defaultAccount { wallets { id'
' walletCurrency balance }}}}", "variables":{}}'
),
data=json.dumps(data), # type: ignore
)
r.raise_for_status()
except Exception as exc:
@@ -96,10 +106,10 @@ class BlinkWallet(LightningBackend):
resp.get("data", {}).get("me", {}).get("defaultAccount", {}).get("wallets")
):
if wallet_dict.get("walletCurrency") == "USD":
self.wallet_ids[Unit.usd] = wallet_dict["id"]
self.wallet_ids[Unit.usd] = wallet_dict["id"] # type: ignore
elif wallet_dict.get("walletCurrency") == "BTC":
self.wallet_ids[Unit.sat] = wallet_dict["id"]
balance = wallet_dict["balance"]
self.wallet_ids[Unit.sat] = wallet_dict["id"] # type: ignore
balance = wallet_dict["balance"] # type: ignore
return StatusResponse(error_message=None, balance=balance)
@@ -144,7 +154,7 @@ class BlinkWallet(LightningBackend):
try:
r = await self.client.post(
url=self.endpoint,
data=json.dumps(data),
data=json.dumps(data), # type: ignore
)
r.raise_for_status()
except Exception as e:
@@ -197,13 +207,16 @@ class BlinkWallet(LightningBackend):
try:
r = await self.client.post(
url=self.endpoint,
data=json.dumps(data),
data=json.dumps(data), # type: ignore
timeout=None,
)
r.raise_for_status()
except Exception as e:
logger.error(f"Blink API error: {str(e)}")
return PaymentResponse(ok=False, error_message=str(e))
return PaymentResponse(
result=PaymentResult.UNKNOWN,
error_message=str(e),
)
resp: dict = r.json()
@@ -211,15 +224,22 @@ class BlinkWallet(LightningBackend):
fee: Union[None, int] = None
if resp.get("data", {}).get("lnInvoicePaymentSend", {}).get("errors"):
error_message = (
resp["data"]["lnInvoicePaymentSend"]["errors"][0].get("message")
resp["data"]["lnInvoicePaymentSend"]["errors"][0].get("message") # type: ignore
or "Unknown error"
)
paid = self.payment_execution_statuses[
resp.get("data", {}).get("lnInvoicePaymentSend", {}).get("status")
]
if paid is None:
error_message = "Invoice already paid."
status_str = resp.get("data", {}).get("lnInvoicePaymentSend", {}).get("status")
result = PAYMENT_EXECUTION_RESULT_MAP[status_str]
if status_str == "ALREADY_PAID":
error_message = "Invoice already paid"
if result == PaymentResult.FAILED:
return PaymentResponse(
result=result,
error_message=error_message,
checking_id=quote.request,
)
if resp.get("data", {}).get("lnInvoicePaymentSend", {}).get("transaction", {}):
fee = (
@@ -230,15 +250,14 @@ class BlinkWallet(LightningBackend):
)
checking_id = quote.request
# we check the payment status to get the preimage
preimage: Union[None, str] = None
payment_status = await self.get_payment_status(checking_id)
if payment_status.paid:
if payment_status.settled:
preimage = payment_status.preimage
return PaymentResponse(
ok=paid,
result=result,
checking_id=checking_id,
fee=Amount(Unit.sat, fee) if fee else None,
preimage=preimage,
@@ -261,22 +280,27 @@ class BlinkWallet(LightningBackend):
"variables": variables,
}
try:
r = await self.client.post(url=self.endpoint, data=json.dumps(data))
r = await self.client.post(url=self.endpoint, data=json.dumps(data)) # type: ignore
r.raise_for_status()
except Exception as e:
logger.error(f"Blink API error: {str(e)}")
return PaymentStatus(paid=None)
return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e))
resp: dict = r.json()
if resp.get("data", {}).get("lnInvoicePaymentStatus", {}).get("errors"):
error_message = (
resp.get("data", {}).get("lnInvoicePaymentStatus", {}).get("errors")
)
if error_message:
logger.error(
"Blink Error",
resp.get("data", {}).get("lnInvoicePaymentStatus", {}).get("errors"),
error_message,
)
return PaymentStatus(paid=None)
paid = self.invoice_statuses[
return PaymentStatus(
result=PaymentResult.UNKNOWN, error_message=error_message
)
result = INVOICE_RESULT_MAP[
resp.get("data", {}).get("lnInvoicePaymentStatus", {}).get("status")
]
return PaymentStatus(paid=paid)
return PaymentStatus(result=result)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
# Checking ID is the payment request and blink wants the payment hash
@@ -311,16 +335,11 @@ class BlinkWallet(LightningBackend):
""",
"variables": variables,
}
try:
r = await self.client.post(
url=self.endpoint,
data=json.dumps(data),
)
r.raise_for_status()
except Exception as e:
logger.error(f"Blink API error: {str(e)}")
return PaymentResponse(ok=False, error_message=str(e))
r = await self.client.post(
url=self.endpoint,
data=json.dumps(data), # type: ignore
)
r.raise_for_status()
resp: dict = r.json()
@@ -332,7 +351,9 @@ class BlinkWallet(LightningBackend):
.get("walletById", {})
.get("transactionsByPaymentHash")
):
return PaymentStatus(paid=None)
return PaymentStatus(
result=PaymentResult.UNKNOWN, error_message="No payment found"
)
all_payments_with_this_hash = (
resp.get("data", {})
@@ -345,12 +366,14 @@ class BlinkWallet(LightningBackend):
# Blink API edge case: for a previously failed payment attempt, it returns the two payments with the same hash
# if there are two payments with the same hash with "direction" == "SEND" and "RECEIVE"
# it means that the payment previously failed and we can ignore the attempt and return
# PaymentStatus(paid=None)
# PaymentStatus(status=FAILED)
if len(all_payments_with_this_hash) == 2 and all(
p["direction"] in [DIRECTION_SEND, DIRECTION_RECEIVE]
p["direction"] in [DIRECTION_SEND, DIRECTION_RECEIVE] # type: ignore
for p in all_payments_with_this_hash
):
return PaymentStatus(paid=None)
return PaymentStatus(
result=PaymentResult.FAILED, error_message="Payment failed"
)
# if there is only one payment with the same hash, it means that the payment might have succeeded
# we only care about the payment with "direction" == "SEND"
@@ -363,15 +386,17 @@ class BlinkWallet(LightningBackend):
None,
)
if not payment:
return PaymentStatus(paid=None)
return PaymentStatus(
result=PaymentResult.UNKNOWN, error_message="No payment found"
)
# we read the status of the payment
paid = self.payment_statuses[payment["status"]]
fee = payment["settlementFee"]
preimage = payment["settlementVia"].get("preImage")
result = PAYMENT_RESULT_MAP[payment["status"]] # type: ignore
fee = payment["settlementFee"] # type: ignore
preimage = payment["settlementVia"].get("preImage") # type: ignore
return PaymentStatus(
paid=paid,
result=result,
fee=Amount(Unit.sat, fee),
preimage=preimage,
)
@@ -404,7 +429,7 @@ class BlinkWallet(LightningBackend):
try:
r = await self.client.post(
url=self.endpoint,
data=json.dumps(data),
data=json.dumps(data), # type: ignore
timeout=PROBE_FEE_TIMEOUT_SEC,
)
r.raise_for_status()
@@ -413,7 +438,7 @@ class BlinkWallet(LightningBackend):
# if there was an error, we simply ignore the response and decide the fees ourselves
fees_response_msat = 0
logger.debug(
f"Blink probe error: {resp['data']['lnInvoiceFeeProbe']['errors'][0].get('message')}"
f"Blink probe error: {resp['data']['lnInvoiceFeeProbe']['errors'][0].get('message')}" # type: ignore
)
else:
@@ -454,5 +479,5 @@ class BlinkWallet(LightningBackend):
amount=amount.to(self.unit, round="up"),
)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: # type: ignore
raise NotImplementedError("paid_invoices_stream not implemented")

View File

@@ -20,11 +20,26 @@ from .base import (
LightningBackend,
PaymentQuoteResponse,
PaymentResponse,
PaymentResult,
PaymentStatus,
StatusResponse,
Unsupported,
)
# https://docs.corelightning.org/reference/lightning-pay
PAYMENT_RESULT_MAP = {
"complete": PaymentResult.SETTLED,
"pending": PaymentResult.PENDING,
"failed": PaymentResult.FAILED,
}
# https://docs.corelightning.org/reference/lightning-listinvoices
INVOICE_RESULT_MAP = {
"paid": PaymentResult.SETTLED,
"unpaid": PaymentResult.PENDING,
"expired": PaymentResult.FAILED,
}
class CLNRestWallet(LightningBackend):
supported_units = {Unit.sat, Unit.msat}
@@ -68,12 +83,6 @@ class CLNRestWallet(LightningBackend):
base_url=self.url, verify=self.cert, headers=self.auth
)
self.last_pay_index = 0
self.statuses = {
"paid": True,
"complete": True,
"failed": False,
"pending": None,
}
async def cleanup(self):
try:
@@ -101,7 +110,7 @@ class CLNRestWallet(LightningBackend):
if len(data) == 0:
return StatusResponse(error_message="no data", balance=0)
balance_msat = int(sum([c["our_amount_msat"] for c in data["channels"]]))
return StatusResponse(error_message=None, balance=balance_msat)
return StatusResponse(balance=balance_msat)
async def create_invoice(
self,
@@ -147,8 +156,6 @@ class CLNRestWallet(LightningBackend):
return InvoiceResponse(
ok=False,
checking_id=None,
payment_request=None,
error_message=error_message,
)
@@ -159,7 +166,6 @@ class CLNRestWallet(LightningBackend):
ok=True,
checking_id=data["payment_hash"],
payment_request=data["bolt11"],
error_message=None,
)
async def pay_invoice(
@@ -169,20 +175,14 @@ class CLNRestWallet(LightningBackend):
invoice = decode(quote.request)
except Bolt11Exception as exc:
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
result=PaymentResult.FAILED,
error_message=str(exc),
)
if not invoice.amount_msat or invoice.amount_msat <= 0:
error_message = "0 amount invoices are not allowed"
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
result=PaymentResult.FAILED,
error_message=error_message,
)
@@ -205,11 +205,7 @@ class CLNRestWallet(LightningBackend):
error_message = "mint does not support MPP"
logger.error(error_message)
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
error_message=error_message,
result=PaymentResult.FAILED, error_message=error_message
)
r = await self.client.post("/v1/pay", data=post_data, timeout=None)
@@ -220,34 +216,20 @@ class CLNRestWallet(LightningBackend):
except Exception:
error_message = r.text
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
error_message=error_message,
result=PaymentResult.FAILED, error_message=error_message
)
data = r.json()
if data["status"] != "complete":
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
error_message="payment failed",
)
checking_id = data["payment_hash"]
preimage = data["payment_preimage"]
fee_msat = data["amount_sent_msat"] - data["amount_msat"]
return PaymentResponse(
ok=self.statuses.get(data["status"]),
result=PAYMENT_RESULT_MAP[data["status"]],
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:
@@ -261,45 +243,44 @@ class CLNRestWallet(LightningBackend):
if r.is_error or "message" in data or data.get("invoices") is None:
raise Exception("error in cln response")
return PaymentStatus(paid=self.statuses.get(data["invoices"][0]["status"]))
return PaymentStatus(
result=INVOICE_RESULT_MAP[data["invoices"][0]["status"]],
)
except Exception as e:
logger.error(f"Error getting invoice status: {e}")
return PaymentStatus(paid=None)
return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e))
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.post(
"/v1/listpays",
data={"payment_hash": checking_id},
)
try:
r.raise_for_status()
data = r.json()
if not data.get("pays"):
# payment not found
logger.error(f"payment not found: {data.get('pays')}")
raise Exception("payment not found")
if r.is_error or "message" in data:
message = data.get("message") or data
raise Exception(f"error in clnrest response: {message}")
pay = data["pays"][0]
fee_msat, preimage = None, None
if self.statuses[pay["status"]]:
# cut off "msat" and convert to int
fee_msat = -int(pay["amount_sent_msat"]) - int(pay["amount_msat"])
preimage = pay["preimage"]
r.raise_for_status()
data = r.json()
if not data.get("pays"):
# payment not found
logger.error(f"payment not found: {data.get('pays')}")
return PaymentStatus(
paid=self.statuses.get(pay["status"]),
fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
preimage=preimage,
result=PaymentResult.UNKNOWN, error_message="payment not found"
)
except Exception as e:
logger.error(f"Error getting payment status: {e}")
return PaymentStatus(paid=None)
if r.is_error or "message" in data:
message = data.get("message") or data
raise Exception(f"error in clnrest response: {message}")
pay = data["pays"][0]
fee_msat, preimage = None, None
if PAYMENT_RESULT_MAP[pay["status"]] == PaymentResult.SETTLED:
fee_msat = -int(pay["amount_sent_msat"]) - int(pay["amount_msat"])
preimage = pay["preimage"]
return PaymentStatus(
result=PAYMENT_RESULT_MAP[pay["status"]],
fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
preimage=preimage,
)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
# call listinvoices to determine the last pay_index

View File

@@ -19,12 +19,27 @@ from .base import (
LightningBackend,
PaymentQuoteResponse,
PaymentResponse,
PaymentResult,
PaymentStatus,
StatusResponse,
Unsupported,
)
from .macaroon import load_macaroon
# https://docs.corelightning.org/reference/lightning-pay
PAYMENT_RESULT_MAP = {
"complete": PaymentResult.SETTLED,
"pending": PaymentResult.PENDING,
"failed": PaymentResult.FAILED,
}
# https://docs.corelightning.org/reference/lightning-listinvoices
INVOICE_RESULT_MAP = {
"paid": PaymentResult.SETTLED,
"unpaid": PaymentResult.PENDING,
"expired": PaymentResult.FAILED,
}
class CoreLightningRestWallet(LightningBackend):
supported_units = {Unit.sat, Unit.msat}
@@ -61,12 +76,6 @@ class CoreLightningRestWallet(LightningBackend):
base_url=self.url, verify=self.cert, headers=self.auth
)
self.last_pay_index = 0
self.statuses = {
"paid": True,
"complete": True,
"failed": False,
"pending": None,
}
async def cleanup(self):
try:
@@ -140,8 +149,6 @@ class CoreLightningRestWallet(LightningBackend):
return InvoiceResponse(
ok=False,
checking_id=None,
payment_request=None,
error_message=error_message,
)
@@ -152,7 +159,6 @@ class CoreLightningRestWallet(LightningBackend):
ok=True,
checking_id=data["payment_hash"],
payment_request=data["bolt11"],
error_message=None,
)
async def pay_invoice(
@@ -162,20 +168,14 @@ class CoreLightningRestWallet(LightningBackend):
invoice = decode(quote.request)
except Bolt11Exception as exc:
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
result=PaymentResult.FAILED,
error_message=str(exc),
)
if not invoice.amount_msat or invoice.amount_msat <= 0:
error_message = "0 amount invoices are not allowed"
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
result=PaymentResult.FAILED,
error_message=error_message,
)
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
@@ -193,38 +193,25 @@ class CoreLightningRestWallet(LightningBackend):
if r.is_error or "error" in r.json():
try:
data = r.json()
error_message = data["error"]
error_message = data["error"]["message"]
except Exception:
error_message = r.text
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
result=PaymentResult.FAILED,
error_message=error_message,
)
data = r.json()
if data["status"] != "complete":
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
error_message="payment failed",
)
checking_id = data["payment_hash"]
preimage = data["payment_preimage"]
fee_msat = data["amount_sent_msat"] - data["amount_msat"]
return PaymentResponse(
ok=self.statuses.get(data["status"]),
result=PAYMENT_RESULT_MAP[data["status"]],
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:
@@ -238,45 +225,44 @@ class CoreLightningRestWallet(LightningBackend):
if r.is_error or "error" in data or data.get("invoices") is None:
raise Exception("error in cln response")
return PaymentStatus(paid=self.statuses.get(data["invoices"][0]["status"]))
return PaymentStatus(
result=INVOICE_RESULT_MAP[data["invoices"][0]["status"]],
)
except Exception as e:
logger.error(f"Error getting invoice status: {e}")
return PaymentStatus(paid=None)
return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e))
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get(
"/v1/pay/listPays",
params={"payment_hash": checking_id},
)
try:
r.raise_for_status()
data = r.json()
if not data.get("pays"):
# payment not found
logger.error(f"payment not found: {data.get('pays')}")
raise Exception("payment not found")
if r.is_error or "error" in data:
message = data.get("error") or data
raise Exception(f"error in corelightning-rest response: {message}")
pay = data["pays"][0]
fee_msat, preimage = None, None
if self.statuses[pay["status"]]:
# cut off "msat" and convert to int
fee_msat = -int(pay["amount_sent_msat"]) - int(pay["amount_msat"])
preimage = pay["preimage"]
r.raise_for_status()
data = r.json()
if not data.get("pays"):
# payment not found
logger.error(f"payment not found: {data.get('pays')}")
return PaymentStatus(
paid=self.statuses.get(pay["status"]),
fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
preimage=preimage,
result=PaymentResult.UNKNOWN, error_message="payment not found"
)
except Exception as e:
logger.error(f"Error getting payment status: {e}")
return PaymentStatus(paid=None)
if r.is_error or "error" in data:
message = data.get("error") or data
raise Exception(f"error in corelightning-rest response: {message}")
pay = data["pays"][0]
fee_msat, preimage = None, None
if PAYMENT_RESULT_MAP.get(pay["status"]) == PaymentResult.SETTLED:
fee_msat = -int(pay["amount_sent_msat"]) - int(pay["amount_msat"])
preimage = pay["preimage"]
return PaymentStatus(
result=PAYMENT_RESULT_MAP[pay["status"]],
fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
preimage=preimage,
)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
# call listinvoices to determine the last pay_index
@@ -285,7 +271,8 @@ class CoreLightningRestWallet(LightningBackend):
data = r.json()
if r.is_error or "error" in data:
raise Exception("error in cln response")
self.last_pay_index = data["invoices"][-1]["pay_index"]
if data.get("invoices"):
self.last_pay_index = data["invoices"][-1]["pay_index"]
while True:
try:
@@ -315,9 +302,11 @@ class CoreLightningRestWallet(LightningBackend):
)
paid_invoce = r.json()
logger.trace(f"paid invoice: {paid_invoce}")
assert self.statuses[
paid_invoce["invoices"][0]["status"]
], "streamed invoice not paid"
if (
INVOICE_RESULT_MAP[paid_invoce["invoices"][0]["status"]]
!= PaymentResult.SETTLED
):
raise Exception("invoice not paid")
assert "invoices" in paid_invoce, "no invoices in response"
assert len(paid_invoce["invoices"]), "no invoices in response"
yield paid_invoce["invoices"][0]["payment_hash"]

View File

@@ -23,6 +23,7 @@ from .base import (
LightningBackend,
PaymentQuoteResponse,
PaymentResponse,
PaymentResult,
PaymentStatus,
StatusResponse,
)
@@ -151,6 +152,14 @@ class FakeWallet(LightningBackend):
if settings.fakewallet_delay_outgoing_payment:
await asyncio.sleep(settings.fakewallet_delay_outgoing_payment)
if settings.fakewallet_pay_invoice_state:
return PaymentResponse(
result=PaymentResult[settings.fakewallet_pay_invoice_state],
checking_id=invoice.payment_hash,
fee=Amount(unit=self.unit, amount=1),
preimage=self.payment_secrets.get(invoice.payment_hash) or "0" * 64,
)
if invoice.payment_hash in self.payment_secrets or settings.fakewallet_brr:
if invoice not in self.paid_invoices_outgoing:
self.paid_invoices_outgoing.append(invoice)
@@ -158,28 +167,33 @@ class FakeWallet(LightningBackend):
raise ValueError("Invoice already paid")
return PaymentResponse(
ok=True,
result=PaymentResult.SETTLED,
checking_id=invoice.payment_hash,
fee=Amount(unit=self.unit, amount=1),
preimage=self.payment_secrets.get(invoice.payment_hash) or "0" * 64,
)
else:
return PaymentResponse(
ok=False, error_message="Only internal invoices can be used!"
result=PaymentResult.FAILED,
error_message="Only internal invoices can be used!",
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
await self.mark_invoice_paid(self.create_dummy_bolt11(checking_id), delay=False)
paid_chceking_ids = [i.payment_hash for i in self.paid_invoices_incoming]
if checking_id in paid_chceking_ids:
paid = True
return PaymentStatus(result=PaymentResult.SETTLED)
else:
paid = False
return PaymentStatus(
result=PaymentResult.UNKNOWN, error_message="Invoice not found"
)
return PaymentStatus(paid=paid)
async def get_payment_status(self, _: str) -> PaymentStatus:
return PaymentStatus(paid=settings.fakewallet_payment_state)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
if settings.fakewallet_payment_state:
return PaymentStatus(
result=PaymentResult[settings.fakewallet_payment_state]
)
return PaymentStatus(result=PaymentResult.SETTLED)
async def get_payment_quote(
self, melt_quote: PostMeltQuoteRequest

View File

@@ -17,6 +17,7 @@ from .base import (
LightningBackend,
PaymentQuoteResponse,
PaymentResponse,
PaymentResult,
PaymentStatus,
StatusResponse,
)
@@ -112,11 +113,18 @@ class LNbitsWallet(LightningBackend):
)
r.raise_for_status()
except Exception:
return PaymentResponse(error_message=r.json()["detail"])
return PaymentResponse(
result=PaymentResult.FAILED, error_message=r.json()["detail"]
)
if r.status_code > 299:
return PaymentResponse(error_message=(f"HTTP status: {r.reason_phrase}",))
return PaymentResponse(
result=PaymentResult.FAILED,
error_message=(f"HTTP status: {r.reason_phrase}",),
)
if "detail" in r.json():
return PaymentResponse(error_message=(r.json()["detail"],))
return PaymentResponse(
result=PaymentResult.FAILED, error_message=(r.json()["detail"],)
)
data: dict = r.json()
checking_id = data["payment_hash"]
@@ -125,7 +133,7 @@ class LNbitsWallet(LightningBackend):
payment: PaymentStatus = await self.get_payment_status(checking_id)
return PaymentResponse(
ok=True,
result=payment.result,
checking_id=checking_id,
fee=payment.fee,
preimage=payment.preimage,
@@ -137,12 +145,28 @@ class LNbitsWallet(LightningBackend):
url=f"{self.endpoint}/api/v1/payments/{checking_id}"
)
r.raise_for_status()
except Exception:
return PaymentStatus(paid=None)
except Exception as e:
return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e))
data: dict = r.json()
if data.get("detail"):
return PaymentStatus(paid=None)
return PaymentStatus(paid=r.json()["paid"])
return PaymentStatus(
result=PaymentResult.UNKNOWN, error_message=data["detail"]
)
if data["paid"]:
result = PaymentResult.SETTLED
elif not data["paid"] and data["details"]["pending"]:
result = PaymentResult.PENDING
elif not data["paid"] and not data["details"]["pending"]:
result = PaymentResult.FAILED
else:
result = PaymentResult.UNKNOWN
return PaymentStatus(
result=result,
fee=Amount(unit=Unit.msat, amount=abs(data["details"]["fee"])),
preimage=data["preimage"],
)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
@@ -150,26 +174,32 @@ class LNbitsWallet(LightningBackend):
url=f"{self.endpoint}/api/v1/payments/{checking_id}"
)
r.raise_for_status()
except Exception:
return PaymentStatus(paid=None)
except httpx.HTTPStatusError as e:
if e.response.status_code != 404:
raise e
return PaymentStatus(
result=PaymentResult.UNKNOWN, error_message=e.response.text
)
data = r.json()
if "paid" not in data and "details" not in data:
return PaymentStatus(paid=None)
return PaymentStatus(
result=PaymentResult.UNKNOWN, error_message="invalid response"
)
paid_value = None
if data["paid"]:
paid_value = True
result = PaymentResult.SETTLED
elif not data["paid"] and data["details"]["pending"]:
paid_value = None
result = PaymentResult.PENDING
elif not data["paid"] and not data["details"]["pending"]:
paid_value = False
result = PaymentResult.FAILED
else:
raise ValueError(f"unexpected value for paid: {data['paid']}")
result = PaymentResult.UNKNOWN
return PaymentStatus(
paid=paid_value,
result=result,
fee=Amount(unit=Unit.msat, amount=abs(data["details"]["fee"])),
preimage=data["preimage"],
preimage=data.get("preimage"),
)
async def get_payment_quote(

View File

@@ -24,6 +24,7 @@ from cashu.lightning.base import (
LightningBackend,
PaymentQuoteResponse,
PaymentResponse,
PaymentResult,
PaymentStatus,
PostMeltQuoteRequest,
StatusResponse,
@@ -31,18 +32,18 @@ from cashu.lightning.base import (
# maps statuses to None, False, True:
# https://api.lightning.community/?python=#paymentpaymentstatus
PAYMENT_STATUSES = {
lnrpc.Payment.PaymentStatus.UNKNOWN: None,
lnrpc.Payment.PaymentStatus.IN_FLIGHT: None,
lnrpc.Payment.PaymentStatus.INITIATED: None,
lnrpc.Payment.PaymentStatus.SUCCEEDED: True,
lnrpc.Payment.PaymentStatus.FAILED: False,
PAYMENT_RESULT_MAP = {
lnrpc.Payment.PaymentStatus.UNKNOWN: PaymentResult.UNKNOWN,
lnrpc.Payment.PaymentStatus.IN_FLIGHT: PaymentResult.PENDING,
lnrpc.Payment.PaymentStatus.INITIATED: PaymentResult.PENDING,
lnrpc.Payment.PaymentStatus.SUCCEEDED: PaymentResult.SETTLED,
lnrpc.Payment.PaymentStatus.FAILED: PaymentResult.FAILED,
}
INVOICE_STATUSES = {
lnrpc.Invoice.InvoiceState.OPEN: None,
lnrpc.Invoice.InvoiceState.SETTLED: True,
lnrpc.Invoice.InvoiceState.CANCELED: None,
lnrpc.Invoice.InvoiceState.ACCEPTED: None,
INVOICE_RESULT_MAP = {
lnrpc.Invoice.InvoiceState.OPEN: PaymentResult.PENDING,
lnrpc.Invoice.InvoiceState.SETTLED: PaymentResult.SETTLED,
lnrpc.Invoice.InvoiceState.CANCELED: PaymentResult.FAILED,
lnrpc.Invoice.InvoiceState.ACCEPTED: PaymentResult.PENDING,
}
@@ -181,13 +182,13 @@ class LndRPCWallet(LightningBackend):
except AioRpcError as e:
error_message = f"SendPaymentSync failed: {e}"
return PaymentResponse(
ok=False,
result=PaymentResult.FAILED,
error_message=error_message,
)
if r.payment_error:
return PaymentResponse(
ok=False,
result=PaymentResult.FAILED,
error_message=r.payment_error,
)
@@ -195,7 +196,7 @@ class LndRPCWallet(LightningBackend):
fee_msat = r.payment_route.total_fees_msat
preimage = r.payment_preimage.hex()
return PaymentResponse(
ok=True,
result=PaymentResult.SETTLED,
checking_id=checking_id,
fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
preimage=preimage,
@@ -262,7 +263,7 @@ class LndRPCWallet(LightningBackend):
except AioRpcError as e:
logger.error(f"QueryRoute or SendToRouteV2 failed: {e}")
return PaymentResponse(
ok=False,
result=PaymentResult.FAILED,
error_message=str(e),
)
@@ -270,16 +271,23 @@ class LndRPCWallet(LightningBackend):
error_message = f"Sending to route failed with code {r.failure.code}"
logger.error(error_message)
return PaymentResponse(
ok=False,
result=PaymentResult.FAILED,
error_message=error_message,
)
ok = r.status == lnrpc.HTLCAttempt.HTLCStatus.SUCCEEDED
result = PaymentResult.UNKNOWN
if r.status == lnrpc.HTLCAttempt.HTLCStatus.SUCCEEDED:
result = PaymentResult.SETTLED
elif r.status == lnrpc.HTLCAttempt.HTLCStatus.IN_FLIGHT:
result = PaymentResult.PENDING
else:
result = PaymentResult.FAILED
checking_id = invoice.payment_hash
fee_msat = r.route.total_fees_msat
preimage = r.preimage.hex()
return PaymentResponse(
ok=ok,
result=result,
checking_id=checking_id,
fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
preimage=preimage,
@@ -299,44 +307,47 @@ class LndRPCWallet(LightningBackend):
except AioRpcError as e:
error_message = f"LookupInvoice failed: {e}"
logger.error(error_message)
return PaymentStatus(paid=None)
return PaymentStatus(result=PaymentResult.UNKNOWN)
return PaymentStatus(paid=INVOICE_STATUSES[r.state])
return PaymentStatus(
result=INVOICE_RESULT_MAP[r.state],
)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
"""
This routine checks the payment status using routerpc.TrackPaymentV2.
"""
# convert checking_id from hex to bytes and some LND magic
try:
checking_id_bytes = bytes.fromhex(checking_id)
except ValueError:
logger.error(f"Couldn't convert {checking_id = } to bytes")
return PaymentStatus(paid=None)
checking_id_bytes = bytes.fromhex(checking_id)
request = routerrpc.TrackPaymentRequest(payment_hash=checking_id_bytes)
try:
async with grpc.aio.secure_channel(
self.endpoint, self.combined_creds
) as channel:
router_stub = routerstub.RouterStub(channel)
async with grpc.aio.secure_channel(
self.endpoint, self.combined_creds
) as channel:
router_stub = routerstub.RouterStub(channel)
try:
async for payment in router_stub.TrackPaymentV2(request):
if payment is not None and payment.status:
preimage = (
payment.payment_preimage
if payment.payment_preimage != "0" * 64
else None
)
return PaymentStatus(
paid=PAYMENT_STATUSES[payment.status],
result=PAYMENT_RESULT_MAP[payment.status],
fee=(
Amount(unit=Unit.msat, amount=payment.fee_msat)
if payment.fee_msat
else None
),
preimage=payment.payment_preimage,
preimage=preimage,
)
except AioRpcError as e:
error_message = f"TrackPaymentV2 failed: {e}"
logger.error(error_message)
except AioRpcError as e:
# status = StatusCode.NOT_FOUND
if e.code() == grpc.StatusCode.NOT_FOUND:
return PaymentStatus(result=PaymentResult.UNKNOWN)
return PaymentStatus(paid=None)
return PaymentStatus(result=PaymentResult.UNKNOWN)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:

View File

@@ -21,11 +21,26 @@ from .base import (
LightningBackend,
PaymentQuoteResponse,
PaymentResponse,
PaymentResult,
PaymentStatus,
StatusResponse,
)
from .macaroon import load_macaroon
PAYMENT_RESULT_MAP = {
"UNKNOWN": PaymentResult.UNKNOWN,
"IN_FLIGHT": PaymentResult.PENDING,
"INITIATED": PaymentResult.PENDING,
"SUCCEEDED": PaymentResult.SETTLED,
"FAILED": PaymentResult.FAILED,
}
INVOICE_RESULT_MAP = {
"OPEN": PaymentResult.PENDING,
"SETTLED": PaymentResult.SETTLED,
"CANCELED": PaymentResult.FAILED,
"ACCEPTED": PaymentResult.PENDING,
}
class LndRestWallet(LightningBackend):
"""https://api.lightning.community/rest/index.html#lnd-rest-api-reference"""
@@ -187,11 +202,7 @@ class LndRestWallet(LightningBackend):
if r.is_error or r.json().get("payment_error"):
error_message = r.json().get("payment_error") or r.text
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
error_message=error_message,
result=PaymentResult.FAILED, error_message=error_message
)
data = r.json()
@@ -199,11 +210,10 @@ class LndRestWallet(LightningBackend):
fee_msat = int(data["payment_route"]["total_fees_msat"])
preimage = base64.b64decode(data["payment_preimage"]).hex()
return PaymentResponse(
ok=True,
result=PaymentResult.SETTLED,
checking_id=checking_id,
fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
preimage=preimage,
error_message=None,
)
async def pay_partial_invoice(
@@ -237,11 +247,7 @@ class LndRestWallet(LightningBackend):
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,
result=PaymentResult.FAILED, error_message=error_message
)
# We need to set the mpp_record for a partial payment
@@ -272,58 +278,52 @@ class LndRestWallet(LightningBackend):
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,
result=PaymentResult.FAILED, error_message=error_message
)
ok = data.get("status") == "SUCCEEDED"
result = PAYMENT_RESULT_MAP.get(data.get("status"), PaymentResult.UNKNOWN)
checking_id = invoice.payment_hash
fee_msat = int(data["route"]["total_fees_msat"])
preimage = base64.b64decode(data["preimage"]).hex()
fee_msat = int(data["route"]["total_fees_msat"]) if data.get("route") else None
preimage = (
base64.b64decode(data["preimage"]).hex() if data.get("preimage") else None
)
return PaymentResponse(
ok=ok,
result=result,
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}")
if r.is_error or not r.json().get("settled"):
# this must also work when checking_id is not a hex recognizable by lnd
# it will return an error and no "settled" attribute on the object
return PaymentStatus(paid=None)
if r.is_error:
logger.error(f"Couldn't get invoice status: {r.text}")
return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=r.text)
return PaymentStatus(paid=True)
data = None
try:
data = r.json()
except json.JSONDecodeError as e:
logger.error(f"Incomprehensible response: {str(e)}")
return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e))
if not data or not data.get("state"):
return PaymentStatus(
result=PaymentResult.UNKNOWN, error_message="no invoice state"
)
return PaymentStatus(
result=INVOICE_RESULT_MAP[data["state"]],
)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
"""
This routine checks the payment status using routerpc.TrackPaymentV2.
"""
# convert checking_id from hex to base64 and some LND magic
try:
checking_id = base64.urlsafe_b64encode(bytes.fromhex(checking_id)).decode(
"ascii"
)
except ValueError:
return PaymentStatus(paid=None)
checking_id = base64.urlsafe_b64encode(bytes.fromhex(checking_id)).decode(
"ascii"
)
url = f"/v2/router/track/{checking_id}"
# check payment.status:
# https://api.lightning.community/?python=#paymentpaymentstatus
statuses = {
"UNKNOWN": None,
"IN_FLIGHT": None,
"SUCCEEDED": True,
"FAILED": False,
}
async with self.client.stream("GET", url, timeout=None) as r:
async for json_line in r.aiter_lines():
try:
@@ -337,27 +337,37 @@ class LndRestWallet(LightningBackend):
else line["error"]
)
logger.error(f"LND get_payment_status error: {message}")
return PaymentStatus(paid=None)
return PaymentStatus(
result=PaymentResult.UNKNOWN, error_message=message
)
payment = line.get("result")
# payment exists
if payment is not None and payment.get("status"):
preimage = (
payment.get("payment_preimage")
if payment.get("payment_preimage") != "0" * 64
else None
)
return PaymentStatus(
paid=statuses[payment["status"]],
result=PAYMENT_RESULT_MAP[payment["status"]],
fee=(
Amount(unit=Unit.msat, amount=payment.get("fee_msat"))
if payment.get("fee_msat")
else None
),
preimage=payment.get("payment_preimage"),
preimage=preimage,
)
else:
return PaymentStatus(paid=None)
return PaymentStatus(
result=PaymentResult.UNKNOWN,
error_message="no payment status",
)
except Exception:
continue
return PaymentStatus(paid=None)
return PaymentStatus(result=PaymentResult.UNKNOWN, error_message="timeout")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:

View File

@@ -1,8 +1,8 @@
# type: ignore
import secrets
from typing import AsyncGenerator, Optional
from typing import AsyncGenerator, Dict, Optional, Union
import httpx
from pydantic import BaseModel
from ..core.base import Amount, MeltQuote, Unit
from ..core.models import PostMeltQuoteRequest
@@ -12,6 +12,7 @@ from .base import (
LightningBackend,
PaymentQuoteResponse,
PaymentResponse,
PaymentResult,
PaymentStatus,
StatusResponse,
)
@@ -19,13 +20,92 @@ from .base import (
USDT = "USDT"
class StrikeAmount(BaseModel):
amount: str
currency: str
class StrikeRate(BaseModel):
amount: str
sourceCurrency: str
targetCurrency: str
class StrikeCreateInvoiceResponse(BaseModel):
invoiceId: str
amount: StrikeAmount
state: str
description: str
class StrikePaymentQuoteResponse(BaseModel):
lightningNetworkFee: StrikeAmount
paymentQuoteId: str
validUntil: str
amount: StrikeAmount
totalFee: StrikeAmount
totalAmount: StrikeAmount
class InvoiceQuoteResponse(BaseModel):
quoteId: str
description: str
lnInvoice: str
expiration: str
expirationInSec: int
targetAmount: StrikeAmount
sourceAmount: StrikeAmount
conversionRate: StrikeRate
class StrikePaymentResponse(BaseModel):
paymentId: str
state: str
result: str
completed: Optional[str]
delivered: Optional[str]
amount: StrikeAmount
totalFee: StrikeAmount
lightningNetworkFee: StrikeAmount
totalAmount: StrikeAmount
lightning: Dict[str, StrikeAmount]
PAYMENT_RESULT_MAP = {
"PENDING": PaymentResult.PENDING,
"COMPLETED": PaymentResult.SETTLED,
"FAILED": PaymentResult.FAILED,
}
INVOICE_RESULT_MAP = {
"PENDING": PaymentResult.PENDING,
"UNPAID": PaymentResult.PENDING,
"PAID": PaymentResult.SETTLED,
"CANCELLED": PaymentResult.FAILED,
}
class StrikeWallet(LightningBackend):
"""https://docs.strike.me/api/"""
supported_units = [Unit.sat, Unit.usd, Unit.eur]
supported_units = set([Unit.sat, Unit.usd, Unit.eur])
supports_description: bool = False
currency_map = {Unit.sat: "BTC", Unit.usd: "USD", Unit.eur: "EUR"}
def fee_int(
self, strike_quote: Union[StrikePaymentQuoteResponse, StrikePaymentResponse]
) -> int:
fee_str = strike_quote.totalFee.amount
if strike_quote.totalFee.currency == self.currency_map[Unit.sat]:
fee = int(float(fee_str) * 1e8)
elif strike_quote.totalFee.currency in [
self.currency_map[Unit.usd],
self.currency_map[Unit.eur],
]:
fee = int(float(fee_str) * 100)
return fee
def __init__(self, unit: Unit, **kwargs):
self.assert_unit_supported(unit)
self.unit = unit
@@ -98,45 +178,29 @@ class StrikeWallet(LightningBackend):
payload = {
"correlationId": secrets.token_hex(16),
"description": "Invoice for order 123",
"description": memo or "Invoice for order 123",
"amount": {"amount": amount.to_float_string(), "currency": self.currency},
}
try:
r = await self.client.post(url=f"{self.endpoint}/v1/invoices", json=payload)
r.raise_for_status()
except Exception:
return InvoiceResponse(
paid=False,
checking_id=None,
payment_request=None,
error_message=r.json()["detail"],
)
return InvoiceResponse(ok=False, error_message=r.json()["detail"])
quote = r.json()
invoice_id = quote.get("invoiceId")
invoice = StrikeCreateInvoiceResponse.parse_obj(r.json())
try:
payload = {"descriptionHash": secrets.token_hex(32)}
r2 = await self.client.post(
f"{self.endpoint}/v1/invoices/{invoice_id}/quote", json=payload
f"{self.endpoint}/v1/invoices/{invoice.invoiceId}/quote", json=payload
)
r2.raise_for_status()
except Exception:
return InvoiceResponse(
paid=False,
checking_id=None,
payment_request=None,
error_message=r.json()["detail"],
)
return InvoiceResponse(ok=False, error_message=r.json()["detail"])
data2 = r2.json()
payment_request = data2.get("lnInvoice")
assert payment_request, "Did not receive an invoice"
checking_id = invoice_id
quote = InvoiceQuoteResponse.parse_obj(r2.json())
return InvoiceResponse(
ok=True,
checking_id=checking_id,
payment_request=payment_request,
error_message=None,
ok=True, checking_id=invoice.invoiceId, payment_request=quote.lnInvoice
)
async def get_payment_quote(
@@ -153,13 +217,18 @@ class StrikeWallet(LightningBackend):
except Exception:
error_message = r.json()["data"]["message"]
raise Exception(error_message)
data = r.json()
strike_quote = StrikePaymentQuoteResponse.parse_obj(r.json())
if strike_quote.amount.currency != self.currency_map[self.unit]:
raise Exception(
f"Expected currency {self.currency_map[self.unit]}, got {strike_quote.amount.currency}"
)
amount = Amount.from_float(float(strike_quote.amount.amount), self.unit)
fee = self.fee_int(strike_quote)
amount = Amount.from_float(float(data.get("amount").get("amount")), self.unit)
quote = PaymentQuoteResponse(
amount=amount,
checking_id=data.get("paymentQuoteId"),
fee=Amount(self.unit, 0),
checking_id=strike_quote.paymentQuoteId,
fee=Amount(self.unit, fee),
)
return quote
@@ -176,49 +245,42 @@ class StrikeWallet(LightningBackend):
except Exception:
error_message = r.json()["data"]["message"]
return PaymentResponse(
ok=None,
checking_id=None,
fee=None,
preimage=None,
error_message=error_message,
result=PaymentResult.FAILED, error_message=error_message
)
data = r.json()
states = {"PENDING": None, "COMPLETED": True, "FAILED": False}
if states[data.get("state")]:
return PaymentResponse(
ok=True, checking_id=None, fee=None, preimage=None, error_message=None
)
else:
return PaymentResponse(
ok=False, checking_id=None, fee=None, preimage=None, error_message=None
)
payment = StrikePaymentResponse.parse_obj(r.json())
fee = self.fee_int(payment)
return PaymentResponse(
result=PAYMENT_RESULT_MAP[payment.state],
checking_id=payment.paymentId,
fee=Amount(self.unit, fee),
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
r = await self.client.get(url=f"{self.endpoint}/v1/invoices/{checking_id}")
r.raise_for_status()
except Exception:
return PaymentStatus(paid=None)
except Exception as e:
return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e))
data = r.json()
states = {"PENDING": None, "UNPAID": None, "PAID": True, "CANCELLED": False}
return PaymentStatus(paid=states[data["state"]])
return PaymentStatus(result=INVOICE_RESULT_MAP[data.get("state")])
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
r = await self.client.get(url=f"{self.endpoint}/v1/payments/{checking_id}")
r.raise_for_status()
except Exception:
return PaymentStatus(paid=None)
data = r.json()
if "paid" not in data and "details" not in data:
return PaymentStatus(paid=None)
payment = StrikePaymentResponse.parse_obj(r.json())
fee = self.fee_int(payment)
return PaymentStatus(
result=PAYMENT_RESULT_MAP[payment.state],
fee=Amount(self.unit, fee),
)
except httpx.HTTPStatusError as exc:
if exc.response.status_code != 404:
raise exc
return PaymentStatus(
result=PaymentResult.UNKNOWN, error_message=exc.response.text
)
return PaymentStatus(
paid=data["paid"],
fee_msat=data["details"]["fee"],
preimage=data["preimage"],
)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: # type: ignore
raise NotImplementedError("paid_invoices_stream not implemented")

View File

@@ -201,16 +201,6 @@ class LedgerCrud(ABC):
) -> None:
...
# @abstractmethod
# async def update_mint_quote_paid(
# self,
# *,
# quote_id: str,
# paid: bool,
# db: Database,
# conn: Optional[Connection] = None,
# ) -> None: ...
@abstractmethod
async def store_melt_quote(
self,
@@ -233,6 +223,16 @@ class LedgerCrud(ABC):
) -> Optional[MeltQuote]:
...
@abstractmethod
async def get_melt_quote_by_request(
self,
*,
request: str,
db: Database,
conn: Optional[Connection] = None,
) -> Optional[MeltQuote]:
...
@abstractmethod
async def update_melt_quote(
self,
@@ -433,8 +433,8 @@ class LedgerCrudSqlite(LedgerCrud):
await (conn or db).execute(
f"""
INSERT INTO {db.table_with_schema('mint_quotes')}
(quote, method, request, checking_id, unit, amount, issued, paid, state, created_time, paid_time)
VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :issued, :paid, :state, :created_time, :paid_time)
(quote, method, request, checking_id, unit, amount, issued, state, created_time, paid_time)
VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :issued, :state, :created_time, :paid_time)
""",
{
"quote": quote.quote,
@@ -443,8 +443,7 @@ class LedgerCrudSqlite(LedgerCrud):
"checking_id": quote.checking_id,
"unit": quote.unit,
"amount": quote.amount,
"issued": quote.issued,
"paid": quote.paid,
"issued": quote.issued, # this is deprecated! we need to store it because we have a NOT NULL constraint | we could also remove the column but sqlite doesn't support that (we would have to make a new table)
"state": quote.state.name,
"created_time": db.to_timestamp(
db.timestamp_from_seconds(quote.created_time) or ""
@@ -513,10 +512,8 @@ class LedgerCrudSqlite(LedgerCrud):
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
f"UPDATE {db.table_with_schema('mint_quotes')} SET issued = :issued, paid = :paid, state = :state, paid_time = :paid_time WHERE quote = :quote",
f"UPDATE {db.table_with_schema('mint_quotes')} SET state = :state, paid_time = :paid_time WHERE quote = :quote",
{
"issued": quote.issued,
"paid": quote.paid,
"state": quote.state.name,
"paid_time": db.to_timestamp(
db.timestamp_from_seconds(quote.paid_time) or ""
@@ -525,23 +522,6 @@ class LedgerCrudSqlite(LedgerCrud):
},
)
# async def update_mint_quote_paid(
# self,
# *,
# quote_id: str,
# paid: bool,
# db: Database,
# conn: Optional[Connection] = None,
# ) -> None:
# await (conn or db).execute(
# f"UPDATE {db.table_with_schema('mint_quotes')} SET paid = ? WHERE"
# " quote = ?",
# (
# paid,
# quote_id,
# ),
# )
async def store_melt_quote(
self,
*,
@@ -552,8 +532,8 @@ class LedgerCrudSqlite(LedgerCrud):
await (conn or db).execute(
f"""
INSERT INTO {db.table_with_schema('melt_quotes')}
(quote, method, request, checking_id, unit, amount, fee_reserve, paid, state, created_time, paid_time, fee_paid, proof, change, expiry)
VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :fee_reserve, :paid, :state, :created_time, :paid_time, :fee_paid, :proof, :change, :expiry)
(quote, method, request, checking_id, unit, amount, fee_reserve, state, created_time, paid_time, fee_paid, proof, change, expiry)
VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :fee_reserve, :state, :created_time, :paid_time, :fee_paid, :proof, :change, :expiry)
""",
{
"quote": quote.quote,
@@ -563,7 +543,6 @@ class LedgerCrudSqlite(LedgerCrud):
"unit": quote.unit,
"amount": quote.amount,
"fee_reserve": quote.fee_reserve or 0,
"paid": quote.paid,
"state": quote.state.name,
"created_time": db.to_timestamp(
db.timestamp_from_seconds(quote.created_time) or ""
@@ -610,8 +589,22 @@ class LedgerCrudSqlite(LedgerCrud):
""",
values,
)
if row is None:
return None
return MeltQuote.from_row(row) if row else None
async def get_melt_quote_by_request(
self,
*,
request: str,
db: Database,
conn: Optional[Connection] = None,
) -> Optional[MeltQuote]:
row = await (conn or db).fetchone(
f"""
SELECT * from {db.table_with_schema('melt_quotes')}
WHERE request = :request
""",
{"request": request},
)
return MeltQuote.from_row(row) if row else None
async def update_melt_quote(
@@ -623,10 +616,9 @@ class LedgerCrudSqlite(LedgerCrud):
) -> None:
await (conn or db).execute(
f"""
UPDATE {db.table_with_schema('melt_quotes')} SET paid = :paid, state = :state, fee_paid = :fee_paid, paid_time = :paid_time, proof = :proof, change = :change WHERE quote = :quote
UPDATE {db.table_with_schema('melt_quotes')} SET state = :state, fee_paid = :fee_paid, paid_time = :paid_time, proof = :proof, change = :change, checking_id = :checking_id WHERE quote = :quote
""",
{
"paid": quote.paid,
"state": quote.state.name,
"fee_paid": quote.fee_paid,
"paid_time": db.to_timestamp(
@@ -637,6 +629,7 @@ class LedgerCrudSqlite(LedgerCrud):
if quote.change
else None,
"quote": quote.quote,
"checking_id": quote.checking_id,
},
)
@@ -678,7 +671,7 @@ class LedgerCrudSqlite(LedgerCrud):
db: Database,
conn: Optional[Connection] = None,
) -> int:
row = await (conn or db).fetchone(
row: List = await (conn or db).fetchone(
f"""
SELECT * from {db.table_with_schema('balance')}
"""

View File

@@ -129,9 +129,9 @@ class DbWriteHelper:
)
if not quote:
raise TransactionError("Mint quote not found.")
if quote.state == MintQuoteState.pending:
if quote.pending:
raise TransactionError("Mint quote already pending.")
if not quote.state == MintQuoteState.paid:
if not quote.paid:
raise TransactionError("Mint quote is not paid yet.")
# set the quote as pending
quote.state = MintQuoteState.pending
@@ -181,15 +181,15 @@ class DbWriteHelper:
quote_copy = quote.copy()
async with self.db.get_connection(
lock_table="melt_quotes",
lock_select_statement=f"checking_id='{quote.checking_id}'",
lock_select_statement=f"quote='{quote.quote}'",
) as conn:
# get melt quote from db and check if it is already pending
quote_db = await self.crud.get_melt_quote(
checking_id=quote.checking_id, db=self.db, conn=conn
quote_id=quote.quote, db=self.db, conn=conn
)
if not quote_db:
raise TransactionError("Melt quote not found.")
if quote_db.state == MeltQuoteState.pending:
if quote_db.pending:
raise TransactionError("Melt quote already pending.")
# set the quote as pending
quote_copy.state = MeltQuoteState.pending

View File

@@ -50,6 +50,8 @@ from ..lightning.base import (
InvoiceResponse,
LightningBackend,
PaymentQuoteResponse,
PaymentResponse,
PaymentResult,
PaymentStatus,
)
from ..mint.crud import LedgerCrudSqlite
@@ -123,12 +125,13 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
)
status = await self.backends[method][unit].status()
if status.error_message:
logger.warning(
logger.error(
"The backend for"
f" {self.backends[method][unit].__class__.__name__} isn't"
f" working properly: '{status.error_message}'",
RuntimeWarning,
)
exit(1)
logger.info(f"Backend balance: {status.balance} {unit.name}")
logger.info(f"Data dir: {settings.cashu_dir}")
@@ -148,40 +151,10 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
)
if not melt_quotes:
return
logger.info("Checking pending melt quotes")
for quote in melt_quotes:
# get pending proofs for quote
pending_proofs = await self.crud.get_pending_proofs_for_quote(
quote_id=quote.quote, db=self.db
)
# check with the backend whether the quote has been paid during downtime
payment = await self.backends[Method[quote.method]][
Unit[quote.unit]
].get_payment_status(quote.checking_id)
if payment.paid:
logger.info(f"Melt quote {quote.quote} state: paid")
quote.paid_time = int(time.time())
quote.paid = True
quote.state = MeltQuoteState.paid
if payment.fee:
quote.fee_paid = payment.fee.to(Unit[quote.unit]).amount
quote.payment_preimage = payment.preimage or ""
await self.crud.update_melt_quote(quote=quote, db=self.db)
# invalidate proofs
await self._invalidate_proofs(
proofs=pending_proofs, quote_id=quote.quote
)
# unset pending
await self.db_write._unset_proofs_pending(pending_proofs)
elif payment.failed:
logger.info(f"Melt quote {quote.quote} state: failed")
# unset pending
await self.db_write._unset_proofs_pending(pending_proofs, spent=False)
elif payment.pending:
logger.info(f"Melt quote {quote.quote} state: pending")
pass
else:
logger.error("Melt quote state unknown")
pass
quote = await self.get_melt_quote(quote_id=quote.quote, purge_unknown=True)
logger.info(f"Melt quote {quote.quote} state: {quote.state}")
# ------- KEYS -------
@@ -447,8 +420,6 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
checking_id=invoice_response.checking_id,
unit=quote_request.unit,
amount=quote_request.amount,
issued=False,
paid=False,
state=MintQuoteState.unpaid,
created_time=int(time.time()),
expiry=expiry,
@@ -476,14 +447,14 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
unit, method = self._verify_and_get_unit_method(quote.unit, quote.method)
if quote.state == MintQuoteState.unpaid:
if quote.unpaid:
if not quote.checking_id:
raise CashuError("quote has no checking id")
logger.trace(f"Lightning: checking invoice {quote.checking_id}")
status: PaymentStatus = await self.backends[method][
unit
].get_invoice_status(quote.checking_id)
if status.paid:
if status.settled:
# change state to paid in one transaction, it could have been marked paid
# by the invoice listener in the mean time
async with self.db.get_connection(
@@ -495,9 +466,8 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
)
if not quote:
raise Exception("quote not found")
if quote.state == MintQuoteState.unpaid:
if quote.unpaid:
logger.trace(f"Setting quote {quote_id} as paid")
quote.paid = True
quote.state = MintQuoteState.paid
quote.paid_time = int(time.time())
await self.crud.update_mint_quote(
@@ -537,11 +507,11 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
output_unit = self.keysets[outputs[0].id].unit
quote = await self.get_mint_quote(quote_id)
if quote.state == MintQuoteState.pending:
if quote.pending:
raise TransactionError("Mint quote already pending.")
if quote.state == MintQuoteState.issued:
if quote.issued:
raise TransactionError("Mint quote already issued.")
if not quote.state == MintQuoteState.paid:
if not quote.paid:
raise QuoteNotPaidError()
previous_state = quote.state
await self.db_write._set_mint_quote_pending(quote_id=quote_id)
@@ -558,7 +528,6 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
quote_id=quote_id, state=previous_state
)
raise e
await self.db_write._unset_mint_quote_pending(
quote_id=quote_id, state=MintQuoteState.issued
)
@@ -585,10 +554,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
raise TransactionError("mint quote already paid")
if mint_quote.issued:
raise TransactionError("mint quote already issued")
if mint_quote.state == MintQuoteState.issued:
raise TransactionError("mint quote already issued")
if mint_quote.state != MintQuoteState.unpaid:
if not mint_quote.unpaid:
raise TransactionError("mint quote is not unpaid")
if not mint_quote.checking_id:
@@ -660,7 +626,6 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
mint_quote = await self.crud.get_mint_quote(request=request, db=self.db)
if mint_quote:
payment_quote = self.create_internal_melt_quote(mint_quote, melt_quote)
else:
# not internal
# verify that the backend supports mpp if the quote request has an amount
@@ -699,7 +664,6 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
checking_id=payment_quote.checking_id,
unit=unit.name,
amount=payment_quote.amount.to(unit).amount,
paid=False,
state=MeltQuoteState.unpaid,
fee_reserve=payment_quote.fee.to(unit).amount,
created_time=int(time.time()),
@@ -712,20 +676,22 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
quote=quote.quote,
amount=quote.amount,
fee_reserve=quote.fee_reserve,
paid=quote.paid,
paid=quote.paid, # deprecated
state=quote.state.value,
expiry=quote.expiry,
)
async def get_melt_quote(self, quote_id: str) -> MeltQuote:
async def get_melt_quote(self, quote_id: str, purge_unknown=False) -> MeltQuote:
"""Returns a melt quote.
If melt quote is not paid yet and no internal mint quote is associated with it,
checks with the backend for the state of the payment request. If the backend
says that the quote has been paid, updates the melt quote in the database.
If the melt quote is pending, checks status of the payment with the backend.
- If settled, sets the quote as paid and invalidates pending proofs (commit).
- If failed, sets the quote as unpaid and unsets pending proofs (rollback).
- If purge_unknown is set, do the same for unknown states as for failed states.
Args:
quote_id (str): ID of the melt quote.
purge_unknown (bool, optional): Rollback unknown payment states to unpaid. Defaults to False.
Raises:
Exception: Quote not found.
@@ -743,21 +709,21 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
# we only check the state with the backend if there is no associated internal
# mint quote for this melt quote
mint_quote = await self.crud.get_mint_quote(
is_internal = await self.crud.get_mint_quote(
request=melt_quote.request, db=self.db
)
if not melt_quote.paid and not mint_quote:
logger.trace(
if melt_quote.pending and not is_internal:
logger.debug(
"Lightning: checking outgoing Lightning payment"
f" {melt_quote.checking_id}"
)
status: PaymentStatus = await self.backends[method][
unit
].get_payment_status(melt_quote.checking_id)
if status.paid:
logger.trace(f"Setting quote {quote_id} as paid")
melt_quote.paid = True
logger.debug(f"State: {status.result}")
if status.settled:
logger.debug(f"Setting quote {quote_id} as paid")
melt_quote.state = MeltQuoteState.paid
if status.fee:
melt_quote.fee_paid = status.fee.to(unit).amount
@@ -766,6 +732,20 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
melt_quote.paid_time = int(time.time())
await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
await self.events.submit(melt_quote)
pending_proofs = await self.crud.get_pending_proofs_for_quote(
quote_id=quote_id, db=self.db
)
await self._invalidate_proofs(proofs=pending_proofs, quote_id=quote_id)
await self.db_write._unset_proofs_pending(pending_proofs)
if status.failed or (purge_unknown and status.unknown):
logger.debug(f"Setting quote {quote_id} as unpaid")
melt_quote.state = MeltQuoteState.unpaid
await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
await self.events.submit(melt_quote)
pending_proofs = await self.crud.get_pending_proofs_for_quote(
quote_id=quote_id, db=self.db
)
await self.db_write._unset_proofs_pending(pending_proofs)
return melt_quote
@@ -798,8 +778,6 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
# we settle the transaction internally
if melt_quote.paid:
raise TransactionError("melt quote already paid")
if melt_quote.state != MeltQuoteState.unpaid:
raise TransactionError("melt quote already paid")
# verify amounts from bolt11 invoice
bolt11_request = melt_quote.request
@@ -830,11 +808,9 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
)
melt_quote.fee_paid = 0 # no internal fees
melt_quote.paid = True
melt_quote.state = MeltQuoteState.paid
melt_quote.paid_time = int(time.time())
mint_quote.paid = True
mint_quote.state = MintQuoteState.paid
mint_quote.paid_time = melt_quote.paid_time
@@ -869,14 +845,13 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
"""
# get melt quote and check if it was already paid
melt_quote = await self.get_melt_quote(quote_id=quote)
if not melt_quote.unpaid:
raise TransactionError(f"melt quote is not unpaid: {melt_quote.state}")
unit, method = self._verify_and_get_unit_method(
melt_quote.unit, melt_quote.method
)
if melt_quote.state != MeltQuoteState.unpaid:
raise TransactionError("melt quote already paid")
# make sure that the outputs (for fee return) are in the same unit as the quote
if outputs:
# _verify_outputs checks if all outputs have the same unit
@@ -917,36 +892,91 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
await self.db_write._verify_spent_proofs_and_set_pending(
proofs, quote_id=melt_quote.quote
)
previous_state = melt_quote.state
melt_quote = await self.db_write._set_melt_quote_pending(melt_quote)
try:
# settle the transaction internally if there is a mint quote with the same payment request
# if the melt corresponds to an internal mint, mark both as paid
melt_quote = await self.melt_mint_settle_internally(melt_quote, proofs)
# quote not paid yet (not internal), pay it with the backend
if not melt_quote.paid and melt_quote.state == MeltQuoteState.unpaid:
if not melt_quote.paid:
logger.debug(f"Lightning: pay invoice {melt_quote.request}")
payment = await self.backends[method][unit].pay_invoice(
melt_quote, melt_quote.fee_reserve * 1000
)
logger.debug(
f"Melt Ok: {payment.ok}: preimage: {payment.preimage},"
f" fee: {payment.fee.str() if payment.fee is not None else 'None'}"
)
if not payment.ok:
raise LightningError(
f"Lightning payment unsuccessful. {payment.error_message}"
try:
payment = await self.backends[method][unit].pay_invoice(
melt_quote, melt_quote.fee_reserve * 1000
)
if payment.fee:
melt_quote.fee_paid = payment.fee.to(
to_unit=unit, round="up"
).amount
if payment.preimage:
melt_quote.payment_preimage = payment.preimage
# set quote as paid
melt_quote.paid = True
melt_quote.state = MeltQuoteState.paid
melt_quote.paid_time = int(time.time())
logger.debug(
f"Melt Result: {str(payment.result)}: preimage: {payment.preimage},"
f" fee: {payment.fee.str() if payment.fee is not None else 'None'}"
)
if (
payment.checking_id
and payment.checking_id != melt_quote.checking_id
):
logger.warning(
f"pay_invoice returned different checking_id: {payment.checking_id} than melt quote: {melt_quote.checking_id}. Will use it for potentially checking payment status later."
)
melt_quote.checking_id = payment.checking_id
await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
except Exception as e:
logger.error(f"Exception during pay_invoice: {e}")
payment = PaymentResponse(
result=PaymentResult.UNKNOWN,
error_message=str(e),
)
match payment.result:
case PaymentResult.FAILED | PaymentResult.UNKNOWN:
# explicitly check payment status for failed or unknown payment states
checking_id = payment.checking_id or melt_quote.checking_id
logger.debug(
f"Payment state is {payment.result}. Checking status for {checking_id}"
)
try:
status = await self.backends[method][
unit
].get_payment_status(checking_id)
except Exception as e:
# Something went wrong, better to keep the proofs in pending state
logger.error(
f"Lightning backend error: could not check payment status. Proofs for melt quote {melt_quote.quote} are stuck as PENDING. Error: {e}"
)
return PostMeltQuoteResponse.from_melt_quote(melt_quote)
match status.result:
case PaymentResult.FAILED | PaymentResult.UNKNOWN:
# NOTE: We only throw a payment error if the payment AND a subsequent status check failed
raise LightningError(
f"Lightning payment failed: {payment.error_message}. Error: {status.error_message}"
)
case _:
logger.error(
f"Payment state is {status.result} and payment was {payment.result}. Proofs for melt quote {melt_quote.quote} are stuck as PENDING."
)
return PostMeltQuoteResponse.from_melt_quote(melt_quote)
case PaymentResult.SETTLED:
# payment successful
if payment.fee:
melt_quote.fee_paid = payment.fee.to(
to_unit=unit, round="up"
).amount
if payment.preimage:
melt_quote.payment_preimage = payment.preimage
# set quote as paid
melt_quote.state = MeltQuoteState.paid
melt_quote.paid_time = int(time.time())
# NOTE: This is the only return point for a successful payment
case PaymentResult.PENDING | _:
logger.debug(
f"Lightning payment is pending: {payment.checking_id}"
)
return PostMeltQuoteResponse.from_melt_quote(melt_quote)
# melt successful, invalidate proofs
await self._invalidate_proofs(proofs=proofs, quote_id=melt_quote.quote)
await self.db_write._unset_proofs_pending(proofs)
# prepare change to compensate wallet for overpaid fees
return_promises: List[BlindedSignature] = []
@@ -963,14 +993,15 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
await self.events.submit(melt_quote)
except Exception as e:
logger.trace(f"Melt exception: {e}")
raise e
finally:
# delete proofs from pending list
await self.db_write._unset_proofs_pending(proofs)
return PostMeltQuoteResponse.from_melt_quote(melt_quote)
return PostMeltQuoteResponse.from_melt_quote(melt_quote)
except Exception as e:
logger.trace(f"Payment has failed: {e}")
await self.db_write._unset_proofs_pending(proofs)
await self.db_write._unset_melt_quote_pending(
quote=melt_quote, state=previous_state
)
raise e
async def swap(
self,

View File

@@ -1,4 +1,5 @@
import copy
from typing import Dict, List
from ..core.base import MintKeyset, Proof
from ..core.crypto.keys import derive_keyset_id, derive_keyset_id_deprecated
@@ -287,7 +288,6 @@ async def m011_add_quote_tables(db: Database):
checking_id TEXT NOT NULL,
unit TEXT NOT NULL,
amount {db.big_int} NOT NULL,
paid BOOL NOT NULL,
issued BOOL NOT NULL,
created_time TIMESTAMP,
paid_time TIMESTAMP,
@@ -296,6 +296,7 @@ async def m011_add_quote_tables(db: Database):
);
"""
# NOTE: We remove the paid BOOL NOT NULL column
)
await conn.execute(
@@ -308,7 +309,6 @@ async def m011_add_quote_tables(db: Database):
unit TEXT NOT NULL,
amount {db.big_int} NOT NULL,
fee_reserve {db.big_int},
paid BOOL NOT NULL,
created_time TIMESTAMP,
paid_time TIMESTAMP,
fee_paid {db.big_int},
@@ -318,13 +318,14 @@ async def m011_add_quote_tables(db: Database):
);
"""
# NOTE: We remove the paid BOOL NOT NULL column
)
await conn.execute(
f"INSERT INTO {db.table_with_schema('mint_quotes')} (quote, method,"
" request, checking_id, unit, amount, paid, issued, created_time,"
" request, checking_id, unit, amount, issued, created_time,"
" paid_time) SELECT id, 'bolt11', bolt11, COALESCE(payment_hash, 'None'),"
f" 'sat', amount, False, issued, COALESCE(created, '{db.timestamp_now_str()}'),"
f" 'sat', amount, issued, COALESCE(created, '{db.timestamp_now_str()}'),"
f" NULL FROM {db.table_with_schema('invoices')} "
)
@@ -788,13 +789,13 @@ async def m020_add_state_to_mint_and_melt_quotes(db: Database):
# and the `paid` and `issued` column respectively
# mint quotes:
async with db.connect() as conn:
rows = await conn.fetchall(
rows: List[Dict] = await conn.fetchall(
f"SELECT * FROM {db.table_with_schema('mint_quotes')}"
)
for row in rows:
if row["issued"]:
if row.get("issued"):
state = "issued"
elif row["paid"]:
elif row.get("paid"):
state = "paid"
else:
state = "unpaid"
@@ -804,10 +805,10 @@ async def m020_add_state_to_mint_and_melt_quotes(db: Database):
# melt quotes:
async with db.connect() as conn:
rows = await conn.fetchall(
rows2: List[Dict] = await conn.fetchall(
f"SELECT * FROM {db.table_with_schema('melt_quotes')}"
)
for row in rows:
for row in rows2:
if row["paid"]:
state = "paid"
else:

View File

@@ -168,7 +168,7 @@ async def mint_quote(
resp = PostMintQuoteResponse(
request=quote.request,
quote=quote.quote,
paid=quote.paid,
paid=quote.paid, # deprecated
state=quote.state.value,
expiry=quote.expiry,
)
@@ -192,7 +192,7 @@ async def get_mint_quote(request: Request, quote: str) -> PostMintQuoteResponse:
resp = PostMintQuoteResponse(
quote=mint_quote.quote,
request=mint_quote.request,
paid=mint_quote.paid,
paid=mint_quote.paid, # deprecated
state=mint_quote.state.value,
expiry=mint_quote.expiry,
)

View File

@@ -3,7 +3,7 @@ from typing import Dict, List, Optional
from fastapi import APIRouter, Request
from loguru import logger
from ..core.base import BlindedMessage, BlindedSignature, ProofSpentState
from ..core.base import BlindedMessage, BlindedSignature
from ..core.errors import CashuError
from ..core.models import (
CheckFeesRequest_deprecated,
@@ -345,13 +345,13 @@ async def check_spendable_deprecated(
spendableList: List[bool] = []
pendingList: List[bool] = []
for proof_state in proofs_state:
if proof_state.state == ProofSpentState.unspent:
if proof_state.unspent:
spendableList.append(True)
pendingList.append(False)
elif proof_state.state == ProofSpentState.spent:
elif proof_state.spent:
spendableList.append(False)
pendingList.append(False)
elif proof_state.state == ProofSpentState.pending:
elif proof_state.pending:
spendableList.append(True)
pendingList.append(True)
return CheckSpendableResponse_deprecated(

View File

@@ -56,8 +56,7 @@ class LedgerTasks(SupportsDb, SupportsBackends, SupportsEvents):
f"Invoice callback dispatcher: quote {quote} trying to set as {MintQuoteState.paid}"
)
# set the quote as paid
if quote.state == MintQuoteState.unpaid:
quote.paid = True
if quote.unpaid:
quote.state = MintQuoteState.paid
await self.crud.update_mint_quote(quote=quote, db=self.db, conn=conn)
logger.trace(

View File

@@ -222,7 +222,7 @@ async def pay(
print(" Error: Balance too low.")
return
send_proofs, fees = await wallet.select_to_send(
wallet.proofs, total_amount, include_fees=True
wallet.proofs, total_amount, include_fees=True, set_reserved=True
)
try:
melt_response = await wallet.melt(
@@ -231,11 +231,25 @@ async def pay(
except Exception as e:
print(f" Error paying invoice: {str(e)}")
return
print(" Invoice paid", end="", flush=True)
if melt_response.payment_preimage and melt_response.payment_preimage != "0" * 64:
print(f" (Preimage: {melt_response.payment_preimage}).")
if (
melt_response.state
and MintQuoteState(melt_response.state) == MintQuoteState.paid
):
print(" Invoice paid", end="", flush=True)
if (
melt_response.payment_preimage
and melt_response.payment_preimage != "0" * 64
):
print(f" (Preimage: {melt_response.payment_preimage}).")
else:
print(".")
elif MintQuoteState(melt_response.state) == MintQuoteState.pending:
print(" Invoice pending.")
elif MintQuoteState(melt_response.state) == MintQuoteState.unpaid:
print(" Invoice unpaid.")
else:
print(".")
print(" Error paying invoice.")
await print_balance(ctx)

View File

@@ -2,12 +2,13 @@ from typing import Optional
import bolt11
from ...core.base import Amount, ProofSpentState, Unit
from ...core.base import Amount, Unit
from ...core.helpers import sum_promises
from ...core.settings import settings
from ...lightning.base import (
InvoiceResponse,
PaymentResponse,
PaymentResult,
PaymentStatus,
StatusResponse,
)
@@ -58,14 +59,14 @@ class LightningWallet(Wallet):
pr (str): bolt11 payment request
Returns:
bool: True if successful
PaymentResponse: containing details of the operation
"""
quote = await self.melt_quote(pr)
total_amount = quote.amount + quote.fee_reserve
assert total_amount > 0, "amount is not positive"
if self.available_balance < total_amount:
print("Error: Balance too low.")
return PaymentResponse(ok=False)
return PaymentResponse(result=PaymentResult.FAILED)
_, send_proofs = await self.swap_to_send(self.proofs, total_amount)
try:
resp = await self.melt(send_proofs, pr, quote.fee_reserve, quote.quote)
@@ -76,14 +77,14 @@ class LightningWallet(Wallet):
invoice_obj = bolt11.decode(pr)
return PaymentResponse(
ok=True,
result=PaymentResult.SETTLED,
checking_id=invoice_obj.payment_hash,
preimage=resp.payment_preimage,
fee=Amount(Unit.msat, fees_paid_sat),
)
except Exception as e:
print("Exception:", e)
return PaymentResponse(ok=False, error_message=str(e))
return PaymentResponse(result=PaymentResult.FAILED, error_message=str(e))
async def get_invoice_status(self, payment_hash: str) -> PaymentStatus:
"""Get lightning invoice status (incoming)
@@ -98,16 +99,16 @@ class LightningWallet(Wallet):
db=self.db, payment_hash=payment_hash, out=False
)
if not invoice:
return PaymentStatus(paid=None)
return PaymentStatus(result=PaymentResult.UNKNOWN)
if invoice.paid:
return PaymentStatus(paid=True)
return PaymentStatus(result=PaymentResult.SETTLED)
try:
# to check the invoice state, we try minting tokens
await self.mint(invoice.amount, id=invoice.id)
return PaymentStatus(paid=True)
return PaymentStatus(result=PaymentResult.SETTLED)
except Exception as e:
print(e)
return PaymentStatus(paid=False)
return PaymentStatus(result=PaymentResult.FAILED)
async def get_payment_status(self, payment_hash: str) -> PaymentStatus:
"""Get lightning payment status (outgoing)
@@ -126,24 +127,30 @@ class LightningWallet(Wallet):
)
if not invoice:
return PaymentStatus(paid=False) # "invoice not found (in db)"
return PaymentStatus(
result=PaymentResult.FAILED
) # "invoice not found (in db)"
if invoice.paid:
return PaymentStatus(paid=True, preimage=invoice.preimage) # "paid (in db)"
return PaymentStatus(
result=PaymentResult.SETTLED, preimage=invoice.preimage
) # "paid (in db)"
proofs = await get_proofs(db=self.db, melt_id=invoice.id)
if not proofs:
return PaymentStatus(paid=False) # "proofs not fount (in db)"
return PaymentStatus(
result=PaymentResult.FAILED
) # "proofs not fount (in db)"
proofs_states = await self.check_proof_state(proofs)
if not proofs_states:
return PaymentStatus(paid=False) # "states not fount"
return PaymentStatus(result=PaymentResult.FAILED) # "states not fount"
if all([p.state == ProofSpentState.pending for p in proofs_states.states]):
return PaymentStatus(paid=None) # "pending (with check)"
if any([p.state == ProofSpentState.spent for p in proofs_states.states]):
if all([p.state.pending for p in proofs_states.states]):
return PaymentStatus(result=PaymentResult.PENDING) # "pending (with check)"
if any([p.state.spent for p in proofs_states.states]):
# NOTE: consider adding this check in wallet.py and mark the invoice as paid if all proofs are spent
return PaymentStatus(paid=True) # "paid (with check)"
if all([p.state == ProofSpentState.unspent for p in proofs_states.states]):
return PaymentStatus(paid=False) # "failed (with check)"
return PaymentStatus(paid=None) # "undefined state"
return PaymentStatus(result=PaymentResult.SETTLED) # "paid (with check)"
if all([p.state.unspent for p in proofs_states.states]):
return PaymentStatus(result=PaymentResult.FAILED) # "failed (with check)"
return PaymentStatus(result=PaymentResult.UNKNOWN) # "undefined state"
async def get_balance(self) -> StatusResponse:
"""Get lightning balance

View File

@@ -13,8 +13,8 @@ from ..core.base import (
BlindedSignature,
DLEQWallet,
Invoice,
MeltQuoteState,
Proof,
ProofSpentState,
Unit,
WalletKeyset,
)
@@ -759,15 +759,18 @@ class Wallet(
status = await super().melt(quote_id, proofs, change_outputs)
# if payment fails
if not status.paid:
# remove the melt_id in proofs
if MeltQuoteState(status.state) == MeltQuoteState.unpaid:
# remove the melt_id in proofs and set reserved to False
for p in proofs:
p.melt_id = None
await update_proof(p, melt_id=None, db=self.db)
p.reserved = False
await update_proof(p, melt_id="", db=self.db)
raise Exception("could not pay invoice.")
elif MeltQuoteState(status.state) == MeltQuoteState.pending:
# payment is still pending
return status
# invoice was paid successfully
await self.invalidate(proofs)
# update paid status in db
@@ -995,7 +998,7 @@ class Wallet(
if check_spendable:
proof_states = await self.check_proof_state(proofs)
for i, state in enumerate(proof_states.states):
if state.state == ProofSpentState.spent:
if state.spent:
invalidated_proofs.append(proofs[i])
else:
invalidated_proofs = proofs

View File

@@ -1,5 +1,5 @@
[mypy]
python_version = 3.9
python_version = 3.10
# disallow_untyped_defs = True
; check_untyped_defs = True
ignore_missing_imports = True

View File

@@ -48,7 +48,7 @@ settings.mint_seed_decryption_key = ""
settings.mint_max_balance = 0
settings.mint_transaction_rate_limit_per_minute = 60
settings.mint_lnd_enable_mpp = True
settings.mint_clnrest_enable_mpp = False
settings.mint_clnrest_enable_mpp = True
settings.mint_input_fee_ppk = 0
settings.db_connection_pool = True

View File

@@ -96,6 +96,7 @@ docker_lightning_unconnected_cli = [
"--rpcserver=lnd-2",
]
def docker_clightning_cli(index):
return [
"docker",
@@ -104,9 +105,9 @@ def docker_clightning_cli(index):
"lightning-cli",
"--network",
"regtest",
"--keywords",
]
def run_cmd(cmd: list) -> str:
timeout = 20
process = Popen(cmd, stdout=PIPE, stderr=PIPE)
@@ -171,11 +172,22 @@ def pay_real_invoice(invoice: str) -> str:
cmd.extend(["payinvoice", "--force", invoice])
return run_cmd(cmd)
def partial_pay_real_invoice(invoice: str, amount: int, node: int) -> str:
cmd = docker_clightning_cli(node)
cmd.extend(["pay", f"bolt11={invoice}", f"partial_msat={amount*1000}"])
return run_cmd(cmd)
def get_real_invoice_cln(sats: int) -> str:
cmd = docker_clightning_cli(1)
cmd.extend(
["invoice", f"{sats*1000}", hashlib.sha256(os.urandom(32)).hexdigest(), "test"]
)
result = run_cmd_json(cmd)
return result["bolt11"]
def mine_blocks(blocks: int = 1) -> str:
cmd = docker_bitcoin_cli.copy()
cmd.extend(["-generate", str(blocks)])

View File

@@ -2,7 +2,7 @@ import asyncio
import datetime
import os
import time
from typing import List
from typing import List, Tuple
import pytest
import pytest_asyncio
@@ -63,7 +63,8 @@ async def test_db_tables(ledger: Ledger):
"SELECT table_name FROM information_schema.tables WHERE table_schema ="
" 'public';"
)
tables = [t[0] for t in tables_res.all()]
tables_all: List[Tuple[str]] = tables_res.all()
tables = [t[0] for t in tables_all]
tables_expected = [
"dbversions",
"keysets",

View File

@@ -3,7 +3,7 @@ import httpx
import pytest
import pytest_asyncio
from cashu.core.base import MeltQuoteState, MintQuoteState, ProofSpentState
from cashu.core.base import MeltQuoteState, MintQuoteState
from cashu.core.models import (
GetInfoResponse,
MintMethodSetting,
@@ -478,7 +478,7 @@ async def test_api_check_state(ledger: Ledger):
response = PostCheckStateResponse.parse_obj(response.json())
assert response
assert len(response.states) == 2
assert response.states[0].state == ProofSpentState.unspent
assert response.states[0].state.unspent
@pytest.mark.asyncio

View File

@@ -1,7 +1,7 @@
import pytest
import pytest_asyncio
from cashu.core.base import MeltQuoteState, MintQuoteState, ProofSpentState
from cashu.core.base import MeltQuoteState, MintQuoteState
from cashu.core.models import PostMeltQuoteRequest
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
@@ -35,14 +35,12 @@ async def test_mint_proofs_pending(wallet1: Wallet, ledger: Ledger):
proofs = wallet1.proofs.copy()
proofs_states_before_split = await wallet1.check_proof_state(proofs)
assert all(
[s.state == ProofSpentState.unspent for s in proofs_states_before_split.states]
)
assert all([s.unspent for s in proofs_states_before_split.states])
await ledger.db_write._verify_spent_proofs_and_set_pending(proofs)
proof_states = await wallet1.check_proof_state(proofs)
assert all([s.state == ProofSpentState.pending for s in proof_states.states])
assert all([s.pending for s in proof_states.states])
await assert_err(wallet1.split(wallet1.proofs, 20), "proofs are pending.")
await ledger.db_write._unset_proofs_pending(proofs)
@@ -50,9 +48,7 @@ async def test_mint_proofs_pending(wallet1: Wallet, ledger: Ledger):
await wallet1.split(proofs, 20)
proofs_states_after_split = await wallet1.check_proof_state(proofs)
assert all(
[s.state == ProofSpentState.spent for s in proofs_states_after_split.states]
)
assert all([s.spent for s in proofs_states_after_split.states])
@pytest.mark.asyncio
@@ -77,7 +73,7 @@ async def test_mint_quote_state_transitions(wallet1: Wallet, ledger: Ledger):
quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db)
assert quote is not None
assert quote.quote == invoice.id
assert quote.state == MintQuoteState.unpaid
assert quote.unpaid
# set pending again
async def set_state(quote, state):
@@ -167,12 +163,12 @@ async def test_melt_quote_set_pending(wallet1: Wallet, ledger: Ledger):
quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db)
assert quote is not None
assert quote.quote == melt_quote.quote
assert quote.state == MeltQuoteState.unpaid
assert quote.unpaid
previous_state = quote.state
await ledger.db_write._set_melt_quote_pending(quote)
quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db)
assert quote is not None
assert quote.state == MeltQuoteState.pending
assert quote.pending
# set unpending
await ledger.db_write._unset_melt_quote_pending(quote, previous_state)
@@ -191,7 +187,7 @@ async def test_melt_quote_state_transitions(wallet1: Wallet, ledger: Ledger):
quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db)
assert quote is not None
assert quote.quote == melt_quote.quote
assert quote.state == MeltQuoteState.unpaid
assert quote.unpaid
# set pending
quote.state = MeltQuoteState.pending
@@ -218,7 +214,7 @@ async def test_mint_quote_set_pending(wallet1: Wallet, ledger: Ledger):
assert invoice is not None
quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db)
assert quote is not None
assert quote.state == MintQuoteState.unpaid
assert quote.unpaid
# pay_if_regtest pays on regtest, get_mint_quote pays on FakeWallet
await pay_if_regtest(invoice.bolt11)
@@ -226,13 +222,13 @@ async def test_mint_quote_set_pending(wallet1: Wallet, ledger: Ledger):
quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db)
assert quote is not None
assert quote.state == MintQuoteState.paid
assert quote.paid
previous_state = MintQuoteState.paid
await ledger.db_write._set_mint_quote_pending(quote.quote)
quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db)
assert quote is not None
assert quote.state == MintQuoteState.pending
assert quote.pending
# try to mint while pending
await assert_err(wallet1.mint(128, id=invoice.id), "Mint quote already pending.")
@@ -243,7 +239,7 @@ async def test_mint_quote_set_pending(wallet1: Wallet, ledger: Ledger):
quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db)
assert quote is not None
assert quote.state == previous_state
assert quote.state == MintQuoteState.paid
assert quote.paid
# # set paid and mint again
# quote.state = MintQuoteState.paid
@@ -254,7 +250,7 @@ async def test_mint_quote_set_pending(wallet1: Wallet, ledger: Ledger):
# check if quote is issued
quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db)
assert quote is not None
assert quote.state == MintQuoteState.issued
assert quote.issued
@pytest.mark.asyncio

View File

@@ -5,10 +5,11 @@ import bolt11
import pytest
import pytest_asyncio
from cashu.core.base import MeltQuote, MeltQuoteState, Proof, ProofSpentState
from cashu.core.base import MeltQuote, MeltQuoteState, Proof
from cashu.core.crypto.aes import AESCipher
from cashu.core.db import Database
from cashu.core.settings import settings
from cashu.lightning.base import PaymentResult
from cashu.mint.crud import LedgerCrudSqlite
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
@@ -143,8 +144,7 @@ async def create_pending_melts(
request="asdasd",
checking_id=check_id,
unit="sat",
paid=False,
state=MeltQuoteState.unpaid,
state=MeltQuoteState.pending,
amount=100,
fee_reserve=1,
)
@@ -173,8 +173,8 @@ async def test_startup_fakewallet_pending_quote_success(ledger: Ledger):
after the startup routine determines that the associated melt quote was paid."""
pending_proof, quote = await create_pending_melts(ledger)
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].state == ProofSpentState.pending
settings.fakewallet_payment_state = True
assert states[0].pending
settings.fakewallet_payment_state = PaymentResult.SETTLED.name
# run startup routinge
await ledger.startup_ledger()
@@ -186,7 +186,7 @@ async def test_startup_fakewallet_pending_quote_success(ledger: Ledger):
# expect that proofs are spent
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].state == ProofSpentState.spent
assert states[0].spent
@pytest.mark.asyncio
@@ -199,8 +199,8 @@ async def test_startup_fakewallet_pending_quote_failure(ledger: Ledger):
"""
pending_proof, quote = await create_pending_melts(ledger)
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].state == ProofSpentState.pending
settings.fakewallet_payment_state = False
assert states[0].pending
settings.fakewallet_payment_state = PaymentResult.FAILED.name
# run startup routinge
await ledger.startup_ledger()
@@ -212,7 +212,7 @@ async def test_startup_fakewallet_pending_quote_failure(ledger: Ledger):
# expect that proofs are unspent
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].state == ProofSpentState.unspent
assert states[0].unspent
@pytest.mark.asyncio
@@ -220,8 +220,8 @@ async def test_startup_fakewallet_pending_quote_failure(ledger: Ledger):
async def test_startup_fakewallet_pending_quote_pending(ledger: Ledger):
pending_proof, quote = await create_pending_melts(ledger)
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].state == ProofSpentState.pending
settings.fakewallet_payment_state = None
assert states[0].pending
settings.fakewallet_payment_state = PaymentResult.PENDING.name
# run startup routinge
await ledger.startup_ledger()
@@ -233,7 +233,28 @@ async def test_startup_fakewallet_pending_quote_pending(ledger: Ledger):
# expect that proofs are still pending
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].state == ProofSpentState.pending
assert states[0].pending
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only for fake wallet")
async def test_startup_fakewallet_pending_quote_unknown(ledger: Ledger):
pending_proof, quote = await create_pending_melts(ledger)
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].pending
settings.fakewallet_payment_state = PaymentResult.UNKNOWN.name
# run startup routinge
await ledger.startup_ledger()
# expect that melt quote is still pending
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
db=ledger.db
)
assert not melt_quotes
# expect that proofs are still pending
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].unspent
@pytest.mark.asyncio
@@ -262,7 +283,6 @@ async def test_startup_regtest_pending_quote_pending(wallet: Wallet, ledger: Led
)
)
await asyncio.sleep(SLEEP_TIME)
# settle_invoice(preimage=preimage)
# run startup routinge
await ledger.startup_ledger()
@@ -275,7 +295,7 @@ async def test_startup_regtest_pending_quote_pending(wallet: Wallet, ledger: Led
# expect that proofs are still pending
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.state == ProofSpentState.pending for s in states])
assert all([s.pending for s in states])
# only now settle the invoice
settle_invoice(preimage=preimage)
@@ -309,7 +329,7 @@ async def test_startup_regtest_pending_quote_success(wallet: Wallet, ledger: Led
await asyncio.sleep(SLEEP_TIME)
# expect that proofs are pending
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.state == ProofSpentState.pending for s in states])
assert all([s.pending for s in states])
settle_invoice(preimage=preimage)
await asyncio.sleep(SLEEP_TIME)
@@ -325,7 +345,7 @@ async def test_startup_regtest_pending_quote_success(wallet: Wallet, ledger: Led
# expect that proofs are spent
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.state == ProofSpentState.spent for s in states])
assert all([s.spent for s in states])
@pytest.mark.asyncio
@@ -360,7 +380,7 @@ async def test_startup_regtest_pending_quote_failure(wallet: Wallet, ledger: Led
# expect that proofs are pending
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.state == ProofSpentState.pending for s in states])
assert all([s.pending for s in states])
cancel_invoice(preimage_hash=preimage_hash)
await asyncio.sleep(SLEEP_TIME)
@@ -376,4 +396,72 @@ async def test_startup_regtest_pending_quote_failure(wallet: Wallet, ledger: Led
# expect that proofs are unspent
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.state == ProofSpentState.unspent for s in states])
assert all([s.unspent for s in states])
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_startup_regtest_pending_quote_unknown(wallet: Wallet, ledger: Ledger):
"""Simulate an unknown payment by executing a pending payment, then
manipulating the melt_quote in the mint's db so that its checking_id
points to an unknown payment."""
# fill wallet
invoice = await wallet.request_mint(64)
await pay_if_regtest(invoice.bolt11)
await wallet.mint(64, id=invoice.id)
assert wallet.balance == 64
# create hodl invoice
preimage, invoice_dict = get_hold_invoice(16)
invoice_payment_request = str(invoice_dict["payment_request"])
invoice_obj = bolt11.decode(invoice_payment_request)
preimage_hash = invoice_obj.payment_hash
# wallet pays the invoice
quote = await wallet.melt_quote(invoice_payment_request)
total_amount = quote.amount + quote.fee_reserve
_, send_proofs = await wallet.swap_to_send(wallet.proofs, total_amount)
asyncio.create_task(
wallet.melt(
proofs=send_proofs,
invoice=invoice_payment_request,
fee_reserve_sat=quote.fee_reserve,
quote_id=quote.quote,
)
)
await asyncio.sleep(SLEEP_TIME)
# expect that proofs are pending
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.pending for s in states])
# before we cancel the payment, we manipulate the melt_quote's checking_id so
# that the mint fails to look up the payment and treats the payment as failed during startup
melt_quote = await ledger.crud.get_melt_quote_by_request(
db=ledger.db, request=invoice_payment_request
)
assert melt_quote
assert melt_quote.pending
# manipulate the checking_id 32 bytes hexadecmial
melt_quote.checking_id = "a" * 64
await ledger.crud.update_melt_quote(quote=melt_quote, db=ledger.db)
await asyncio.sleep(SLEEP_TIME)
# run startup routinge
await ledger.startup_ledger()
# expect that no melt quote is pending
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
db=ledger.db
)
assert not melt_quotes
# expect that proofs are unspent
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.unspent for s in states])
# clean up
cancel_invoice(preimage_hash=preimage_hash)

View File

@@ -98,11 +98,10 @@ async def test_blink_pay_invoice():
unit="sat",
amount=100,
fee_reserve=12,
paid=False,
state=MeltQuoteState.unpaid,
)
payment = await blink.pay_invoice(quote, 1000)
assert payment.ok
assert payment.settled
assert payment.fee
assert payment.fee.amount == 10
assert payment.error_message is None
@@ -131,11 +130,10 @@ async def test_blink_pay_invoice_failure():
unit="sat",
amount=100,
fee_reserve=12,
paid=False,
state=MeltQuoteState.unpaid,
)
payment = await blink.pay_invoice(quote, 1000)
assert not payment.ok
assert not payment.settled
assert payment.fee is None
assert payment.error_message
assert "This is the error" in payment.error_message
@@ -155,7 +153,7 @@ async def test_blink_get_invoice_status():
}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
status = await blink.get_invoice_status("123")
assert status.paid
assert status.settled
@respx.mock
@@ -183,7 +181,7 @@ async def test_blink_get_payment_status():
}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
status = await blink.get_payment_status(payment_request)
assert status.paid
assert status.settled
assert status.fee
assert status.fee.amount == 10
assert status.preimage == "123"

297
tests/test_mint_melt.py Normal file
View File

@@ -0,0 +1,297 @@
from typing import List, Tuple
import pytest
import pytest_asyncio
from cashu.core.base import MeltQuote, MeltQuoteState, Proof
from cashu.core.errors import LightningError
from cashu.core.models import PostMeltQuoteRequest
from cashu.core.settings import settings
from cashu.lightning.base import PaymentResult
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import (
is_regtest,
)
SEED = "TEST_PRIVATE_KEY"
DERIVATION_PATH = "m/0'/0'/0'"
DECRYPTON_KEY = "testdecryptionkey"
ENCRYPTED_SEED = "U2FsdGVkX1_7UU_-nVBMBWDy_9yDu4KeYb7MH8cJTYQGD4RWl82PALH8j-HKzTrI"
async def assert_err(f, msg):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
assert exc.args[0] == msg, Exception(
f"Expected error: {msg}, got: {exc.args[0]}"
)
def assert_amt(proofs: List[Proof], expected: int):
"""Assert amounts the proofs contain."""
assert [p.amount for p in proofs] == expected
@pytest_asyncio.fixture(scope="function")
async def wallet(ledger: Ledger):
wallet1 = await Wallet.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet_mint_api_deprecated",
name="wallet_mint_api_deprecated",
)
await wallet1.load_mint()
yield wallet1
async def create_pending_melts(
ledger: Ledger, check_id: str = "checking_id"
) -> Tuple[Proof, MeltQuote]:
"""Helper function for startup tests for fakewallet. Creates fake pending melt
quote and fake proofs that are in the pending table that look like they're being
used to pay the pending melt quote."""
quote_id = "quote_id"
quote = MeltQuote(
quote=quote_id,
method="bolt11",
request="asdasd",
checking_id=check_id,
unit="sat",
state=MeltQuoteState.pending,
amount=100,
fee_reserve=1,
)
await ledger.crud.store_melt_quote(
quote=quote,
db=ledger.db,
)
pending_proof = Proof(amount=123, C="asdasd", secret="asdasd", id=quote_id)
await ledger.crud.set_proof_pending(
db=ledger.db,
proof=pending_proof,
quote_id=quote_id,
)
# expect a pending melt quote
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
db=ledger.db
)
assert melt_quotes
return pending_proof, quote
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_fakewallet_pending_quote_get_melt_quote_success(ledger: Ledger):
"""Startup routine test. Expects that a pending proofs are removed form the pending db
after the startup routine determines that the associated melt quote was paid."""
pending_proof, quote = await create_pending_melts(ledger)
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].pending
settings.fakewallet_payment_state = PaymentResult.SETTLED.name
# get_melt_quote should check the payment status and update the db
quote2 = await ledger.get_melt_quote(quote_id=quote.quote)
assert quote2.state == MeltQuoteState.paid
# expect that no pending tokens are in db anymore
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
db=ledger.db
)
assert not melt_quotes
# expect that proofs are spent
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].spent
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_fakewallet_pending_quote_get_melt_quote_pending(ledger: Ledger):
"""Startup routine test. Expects that a pending proofs are removed form the pending db
after the startup routine determines that the associated melt quote was paid."""
pending_proof, quote = await create_pending_melts(ledger)
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].pending
settings.fakewallet_payment_state = PaymentResult.PENDING.name
# get_melt_quote should check the payment status and update the db
quote2 = await ledger.get_melt_quote(quote_id=quote.quote)
assert quote2.state == MeltQuoteState.pending
# expect that pending tokens are still in db
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
db=ledger.db
)
assert melt_quotes
# expect that proofs are pending
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].pending
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_fakewallet_pending_quote_get_melt_quote_failed(ledger: Ledger):
"""Startup routine test. Expects that a pending proofs are removed form the pending db
after the startup routine determines that the associated melt quote was paid."""
pending_proof, quote = await create_pending_melts(ledger)
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].pending
settings.fakewallet_payment_state = PaymentResult.FAILED.name
# get_melt_quote should check the payment status and update the db
quote2 = await ledger.get_melt_quote(quote_id=quote.quote)
assert quote2.state == MeltQuoteState.unpaid
# expect that pending tokens are still in db
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
db=ledger.db
)
assert not melt_quotes
# expect that proofs are pending
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].unspent
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_fakewallet_pending_quote_get_melt_quote_unknown(ledger: Ledger):
"""Startup routine test. Expects that a pending proofs are removed form the pending db
after the startup routine determines that the associated melt quote was paid."""
pending_proof, quote = await create_pending_melts(ledger)
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].pending
settings.fakewallet_payment_state = PaymentResult.UNKNOWN.name
# get_melt_quote(..., purge_unknown=True) should check the payment status and update the db
quote2 = await ledger.get_melt_quote(quote_id=quote.quote, purge_unknown=True)
assert quote2.state == MeltQuoteState.unpaid
# expect that pending tokens are still in db
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
db=ledger.db
)
assert not melt_quotes
# expect that proofs are pending
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].unspent
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_melt_lightning_pay_invoice_settled(ledger: Ledger, wallet: Wallet):
invoice = await wallet.request_mint(64)
await ledger.get_mint_quote(invoice.id) # fakewallet: set the quote to paid
await wallet.mint(64, id=invoice.id)
# invoice_64_sat = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0"
invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8"
quote_id = (
await ledger.melt_quote(
PostMeltQuoteRequest(unit="sat", request=invoice_62_sat)
)
).quote
# quote = await ledger.get_melt_quote(quote_id)
settings.fakewallet_payment_state = PaymentResult.SETTLED.name
settings.fakewallet_pay_invoice_state = PaymentResult.SETTLED.name
melt_response = await ledger.melt(proofs=wallet.proofs, quote=quote_id)
assert melt_response.state == MeltQuoteState.paid.value
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_melt_lightning_pay_invoice_failed_failed(ledger: Ledger, wallet: Wallet):
invoice = await wallet.request_mint(64)
await ledger.get_mint_quote(invoice.id) # fakewallet: set the quote to paid
await wallet.mint(64, id=invoice.id)
# invoice_64_sat = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0"
invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8"
quote_id = (
await ledger.melt_quote(
PostMeltQuoteRequest(unit="sat", request=invoice_62_sat)
)
).quote
# quote = await ledger.get_melt_quote(quote_id)
settings.fakewallet_payment_state = PaymentResult.FAILED.name
settings.fakewallet_pay_invoice_state = PaymentResult.FAILED.name
try:
await ledger.melt(proofs=wallet.proofs, quote=quote_id)
raise AssertionError("Expected LightningError")
except LightningError:
pass
settings.fakewallet_payment_state = PaymentResult.UNKNOWN.name
settings.fakewallet_pay_invoice_state = PaymentResult.FAILED.name
try:
await ledger.melt(proofs=wallet.proofs, quote=quote_id)
raise AssertionError("Expected LightningError")
except LightningError:
pass
settings.fakewallet_payment_state = PaymentResult.FAILED.name
settings.fakewallet_pay_invoice_state = PaymentResult.UNKNOWN.name
try:
await ledger.melt(proofs=wallet.proofs, quote=quote_id)
raise AssertionError("Expected LightningError")
except LightningError:
pass
settings.fakewallet_payment_state = PaymentResult.UNKNOWN.name
settings.fakewallet_pay_invoice_state = PaymentResult.UNKNOWN.name
try:
await ledger.melt(proofs=wallet.proofs, quote=quote_id)
raise AssertionError("Expected LightningError")
except LightningError:
pass
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_melt_lightning_pay_invoice_failed_settled(
ledger: Ledger, wallet: Wallet
):
invoice = await wallet.request_mint(64)
await ledger.get_mint_quote(invoice.id) # fakewallet: set the quote to paid
await wallet.mint(64, id=invoice.id)
invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8"
quote_id = (
await ledger.melt_quote(
PostMeltQuoteRequest(unit="sat", request=invoice_62_sat)
)
).quote
settings.fakewallet_pay_invoice_state = PaymentResult.FAILED.name
settings.fakewallet_payment_state = PaymentResult.SETTLED.name
melt_response = await ledger.melt(proofs=wallet.proofs, quote=quote_id)
assert melt_response.state == MeltQuoteState.pending.value
# expect that proofs are pending
states = await ledger.db_read.get_proofs_states([p.Y for p in wallet.proofs])
assert all([s.pending for s in states])
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_melt_lightning_pay_invoice_failed_pending(
ledger: Ledger, wallet: Wallet
):
invoice = await wallet.request_mint(64)
await ledger.get_mint_quote(invoice.id) # fakewallet: set the quote to paid
await wallet.mint(64, id=invoice.id)
invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8"
quote_id = (
await ledger.melt_quote(
PostMeltQuoteRequest(unit="sat", request=invoice_62_sat)
)
).quote
settings.fakewallet_pay_invoice_state = PaymentResult.FAILED.name
settings.fakewallet_payment_state = PaymentResult.PENDING.name
melt_response = await ledger.melt(proofs=wallet.proofs, quote=quote_id)
assert melt_response.state == MeltQuoteState.pending.value
# expect that proofs are pending
states = await ledger.db_read.get_proofs_states([p.Y for p in wallet.proofs])
assert all([s.pending for s in states])

View File

@@ -1,7 +1,7 @@
import pytest
import pytest_asyncio
from cashu.core.base import MeltQuoteState, MintQuoteState
from cashu.core.base import MeltQuoteState
from cashu.core.helpers import sum_proofs
from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest
from cashu.mint.ledger import Ledger
@@ -38,9 +38,8 @@ async def wallet1(ledger: Ledger):
async def test_melt_internal(wallet1: Wallet, ledger: Ledger):
# mint twice so we have enough to pay the second invoice back
invoice = await wallet1.request_mint(128)
await pay_if_regtest(invoice.bolt11)
await ledger.get_mint_quote(invoice.id)
await wallet1.mint(128, id=invoice.id)
await pay_if_regtest(invoice.bolt11)
assert wallet1.balance == 128
# create a mint quote so that we can melt to it internally
@@ -58,14 +57,14 @@ async def test_melt_internal(wallet1: Wallet, ledger: Ledger):
melt_quote_pre_payment = await ledger.get_melt_quote(melt_quote.quote)
assert not melt_quote_pre_payment.paid, "melt quote should not be paid"
assert melt_quote_pre_payment.state == MeltQuoteState.unpaid
assert melt_quote_pre_payment.unpaid
keep_proofs, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 64)
await ledger.melt(proofs=send_proofs, quote=melt_quote.quote)
melt_quote_post_payment = await ledger.get_melt_quote(melt_quote.quote)
assert melt_quote_post_payment.paid, "melt quote should be paid"
assert melt_quote_post_payment.state == MeltQuoteState.paid
assert melt_quote_post_payment.paid
@pytest.mark.asyncio
@@ -92,25 +91,25 @@ async def test_melt_external(wallet1: Wallet, ledger: Ledger):
melt_quote_pre_payment = await ledger.get_melt_quote(melt_quote.quote)
assert not melt_quote_pre_payment.paid, "melt quote should not be paid"
assert melt_quote_pre_payment.state == MeltQuoteState.unpaid
assert melt_quote_pre_payment.unpaid
assert not melt_quote.paid, "melt quote should not be paid"
await ledger.melt(proofs=send_proofs, quote=melt_quote.quote)
melt_quote_post_payment = await ledger.get_melt_quote(melt_quote.quote)
assert melt_quote_post_payment.paid, "melt quote should be paid"
assert melt_quote_post_payment.state == MeltQuoteState.paid
assert melt_quote_post_payment.paid
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
async def test_mint_internal(wallet1: Wallet, ledger: Ledger):
invoice = await wallet1.request_mint(128)
await pay_if_regtest(invoice.bolt11)
await ledger.get_mint_quote(invoice.id)
mint_quote = await ledger.get_mint_quote(invoice.id)
assert mint_quote.paid, "mint quote should be paid"
assert mint_quote.state == MintQuoteState.paid
assert mint_quote.paid
output_amounts = [128]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
@@ -125,8 +124,8 @@ async def test_mint_internal(wallet1: Wallet, ledger: Ledger):
)
mint_quote_after_payment = await ledger.get_mint_quote(invoice.id)
assert mint_quote_after_payment.paid, "mint quote should be paid"
assert mint_quote_after_payment.state == MintQuoteState.issued
assert mint_quote_after_payment.issued, "mint quote should be issued"
assert mint_quote_after_payment.issued
@pytest.mark.asyncio
@@ -134,11 +133,11 @@ async def test_mint_internal(wallet1: Wallet, ledger: Ledger):
async def test_mint_external(wallet1: Wallet, ledger: Ledger):
quote = await ledger.mint_quote(PostMintQuoteRequest(amount=128, unit="sat"))
assert not quote.paid, "mint quote should not be paid"
assert quote.state == MintQuoteState.unpaid
assert quote.unpaid
mint_quote = await ledger.get_mint_quote(quote.quote)
assert not mint_quote.paid, "mint quote already paid"
assert mint_quote.state == MintQuoteState.unpaid
assert mint_quote.unpaid
await assert_err(
wallet1.mint(128, id=quote.quote),
@@ -149,7 +148,7 @@ async def test_mint_external(wallet1: Wallet, ledger: Ledger):
mint_quote = await ledger.get_mint_quote(quote.quote)
assert mint_quote.paid, "mint quote should be paid"
assert mint_quote.state == MintQuoteState.paid
assert mint_quote.paid
output_amounts = [128]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
@@ -159,8 +158,7 @@ async def test_mint_external(wallet1: Wallet, ledger: Ledger):
await ledger.mint(outputs=outputs, quote_id=quote.quote)
mint_quote_after_payment = await ledger.get_mint_quote(quote.quote)
assert mint_quote_after_payment.paid, "mint quote should be paid"
assert mint_quote_after_payment.state == MintQuoteState.issued
assert mint_quote_after_payment.issued, "mint quote should be issued"
@pytest.mark.asyncio

View File

@@ -1,17 +1,23 @@
import asyncio
import bolt11
import pytest
import pytest_asyncio
from cashu.core.base import ProofSpentState
from cashu.core.base import Amount, MeltQuote, MeltQuoteState, Method, Unit
from cashu.core.models import PostMeltQuoteRequest
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import (
SLEEP_TIME,
cancel_invoice,
get_hold_invoice,
get_real_invoice,
get_real_invoice_cln,
is_fake,
pay_if_regtest,
pay_real_invoice,
settle_invoice,
)
@@ -27,6 +33,243 @@ async def wallet():
yield wallet
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_lightning_create_invoice(ledger: Ledger):
invoice = await ledger.backends[Method.bolt11][Unit.sat].create_invoice(
Amount(Unit.sat, 1000)
)
assert invoice.ok
assert invoice.payment_request
assert invoice.checking_id
# TEST 2: check the invoice status
status = await ledger.backends[Method.bolt11][Unit.sat].get_invoice_status(
invoice.checking_id
)
assert status.pending
# settle the invoice
await pay_if_regtest(invoice.payment_request)
# TEST 3: check the invoice status
status = await ledger.backends[Method.bolt11][Unit.sat].get_invoice_status(
invoice.checking_id
)
assert status.settled
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_lightning_get_payment_quote(ledger: Ledger):
invoice_dict = get_real_invoice(64)
request = invoice_dict["payment_request"]
payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote(
PostMeltQuoteRequest(request=request, unit=Unit.sat.name)
)
assert payment_quote.amount == Amount(Unit.sat, 64)
assert payment_quote.checking_id
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_lightning_pay_invoice(ledger: Ledger):
invoice_dict = get_real_invoice(64)
request = invoice_dict["payment_request"]
quote = MeltQuote(
quote="test",
method=Method.bolt11.name,
unit=Unit.sat.name,
state=MeltQuoteState.unpaid,
request=request,
checking_id="test",
amount=64,
fee_reserve=0,
)
payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice(quote, 1000)
assert payment.settled
assert payment.preimage
assert payment.checking_id
assert not payment.error_message
# TEST 2: check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
payment.checking_id
)
assert status.settled
assert status.preimage
assert not status.error_message
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_lightning_pay_invoice_failure(ledger: Ledger):
# create an invoice with the external CLN node and pay it with the external LND so that our mint backend can't pay it
request = get_real_invoice_cln(64)
# pay the invoice so that the attempt later fails
pay_real_invoice(request)
# we call get_payment_quote to get a checking_id that we will use to check for the failed pending state later with get_payment_status
payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote(
PostMeltQuoteRequest(request=request, unit=Unit.sat.name)
)
checking_id = payment_quote.checking_id
# TEST 1: check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
checking_id
)
assert status.unknown
# TEST 2: pay the invoice
quote = MeltQuote(
quote="test",
method=Method.bolt11.name,
unit=Unit.sat.name,
state=MeltQuoteState.unpaid,
request=request,
checking_id="test",
amount=64,
fee_reserve=0,
)
payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice(quote, 1000)
assert payment.failed
assert not payment.preimage
assert payment.error_message
assert not payment.checking_id
# TEST 3: check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
checking_id
)
assert status.failed or status.unknown
assert not status.preimage
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_lightning_pay_invoice_pending_success(ledger: Ledger):
# create a hold invoice
preimage, invoice_dict = get_hold_invoice(64)
request = str(invoice_dict["payment_request"])
# we call get_payment_quote to get a checking_id that we will use to check for the failed pending state later with get_payment_status
payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote(
PostMeltQuoteRequest(request=request, unit=Unit.sat.name)
)
checking_id = payment_quote.checking_id
# pay the invoice
quote = MeltQuote(
quote="test",
method=Method.bolt11.name,
unit=Unit.sat.name,
state=MeltQuoteState.unpaid,
request=request,
checking_id=checking_id,
amount=64,
fee_reserve=0,
)
async def pay():
payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice(
quote, 1000
)
return payment
task = asyncio.create_task(pay())
await asyncio.sleep(SLEEP_TIME)
# check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
quote.checking_id
)
assert status.pending
# settle the invoice
settle_invoice(preimage=preimage)
await asyncio.sleep(SLEEP_TIME)
# collect the payment
payment = await task
assert payment.settled
assert payment.preimage
assert payment.checking_id
assert not payment.error_message
# check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
quote.checking_id
)
assert status.settled
assert status.preimage
assert not status.error_message
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_lightning_pay_invoice_pending_failure(ledger: Ledger):
# create a hold invoice
preimage, invoice_dict = get_hold_invoice(64)
request = str(invoice_dict["payment_request"])
payment_hash = bolt11.decode(request).payment_hash
# we call get_payment_quote to get a checking_id that we will use to check for the failed pending state later with get_payment_status
payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote(
PostMeltQuoteRequest(request=request, unit=Unit.sat.name)
)
checking_id = payment_quote.checking_id
# pay the invoice
quote = MeltQuote(
quote="test",
method=Method.bolt11.name,
unit=Unit.sat.name,
state=MeltQuoteState.unpaid,
request=request,
checking_id=checking_id,
amount=64,
fee_reserve=0,
)
async def pay():
payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice(
quote, 1000
)
return payment
task = asyncio.create_task(pay())
await asyncio.sleep(SLEEP_TIME)
# check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
quote.checking_id
)
assert status.pending
# cancel the invoice
cancel_invoice(payment_hash)
await asyncio.sleep(SLEEP_TIME)
# collect the payment
payment = await task
assert payment.failed
assert not payment.preimage
# assert payment.error_message
# check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
quote.checking_id
)
assert (
status.failed or status.unknown
) # some backends send unknown instead of failed if they can't find the payment
assert not status.preimage
# assert status.error_message
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger):
@@ -63,7 +306,7 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger):
# expect that proofs are still pending
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.state == ProofSpentState.pending for s in states])
assert all([s.pending for s in states])
# only now settle the invoice
settle_invoice(preimage=preimage)
@@ -71,7 +314,7 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger):
# expect that proofs are now spent
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.state == ProofSpentState.spent for s in states])
assert all([s.spent for s in states])
# expect that no melt quote is pending
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(

View File

@@ -4,7 +4,7 @@ import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from cashu.lightning.base import InvoiceResponse, PaymentStatus
from cashu.lightning.base import InvoiceResponse, PaymentResult, PaymentStatus
from cashu.wallet.api.app import app
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
@@ -29,8 +29,8 @@ async def test_invoice(wallet: Wallet):
response = client.post("/lightning/create_invoice?amount=100")
assert response.status_code == 200
invoice_response = InvoiceResponse.parse_obj(response.json())
state = PaymentStatus(paid=False)
while not state.paid:
state = PaymentStatus(result=PaymentResult.PENDING)
while state.pending:
print("checking invoice state")
response2 = client.get(
f"/lightning/invoice_state?payment_hash={invoice_response.checking_id}"
@@ -171,8 +171,8 @@ async def test_flow(wallet: Wallet):
initial_balance = response.json()["balance"]
response = client.post("/lightning/create_invoice?amount=100")
invoice_response = InvoiceResponse.parse_obj(response.json())
state = PaymentStatus(paid=False)
while not state.paid:
state = PaymentStatus(result=PaymentResult.PENDING)
while state.pending:
print("checking invoice state")
response2 = client.get(
f"/lightning/invoice_state?payment_hash={invoice_response.checking_id}"

View File

@@ -83,7 +83,7 @@ async def test_check_invoice_internal(wallet: LightningWallet):
assert invoice.payment_request
assert invoice.checking_id
status = await wallet.get_invoice_status(invoice.checking_id)
assert status.paid
assert status.settled
@pytest.mark.asyncio
@@ -94,10 +94,10 @@ async def test_check_invoice_external(wallet: LightningWallet):
assert invoice.payment_request
assert invoice.checking_id
status = await wallet.get_invoice_status(invoice.checking_id)
assert not status.paid
assert not status.settled
await pay_if_regtest(invoice.payment_request)
status = await wallet.get_invoice_status(invoice.checking_id)
assert status.paid
assert status.settled
@pytest.mark.asyncio
@@ -115,12 +115,12 @@ async def test_pay_invoice_internal(wallet: LightningWallet):
assert invoice2.payment_request
status = await wallet.pay_invoice(invoice2.payment_request)
assert status.ok
assert status.settled
# check payment
assert invoice2.checking_id
status = await wallet.get_payment_status(invoice2.checking_id)
assert status.paid
assert status.settled
@pytest.mark.asyncio
@@ -132,16 +132,16 @@ async def test_pay_invoice_external(wallet: LightningWallet):
assert invoice.checking_id
await pay_if_regtest(invoice.payment_request)
status = await wallet.get_invoice_status(invoice.checking_id)
assert status.paid
assert status.settled
assert wallet.available_balance >= 64
# pay invoice
invoice_real = get_real_invoice(16)
status = await wallet.pay_invoice(invoice_real["payment_request"])
assert status.ok
assert status.settled
# check payment
assert status.checking_id
status = await wallet.get_payment_status(status.checking_id)
assert status.paid
assert status.settled

View File

@@ -7,7 +7,7 @@ from typing import List
import pytest
import pytest_asyncio
from cashu.core.base import Proof, ProofSpentState
from cashu.core.base import Proof
from cashu.core.crypto.secp import PrivateKey, PublicKey
from cashu.core.migrations import migrate_databases
from cashu.core.p2pk import SigFlags
@@ -80,7 +80,7 @@ async def test_p2pk(wallet1: Wallet, wallet2: Wallet):
await wallet2.redeem(send_proofs)
proof_states = await wallet2.check_proof_state(send_proofs)
assert all([p.state == ProofSpentState.spent for p in proof_states.states])
assert all([p.spent for p in proof_states.states])
if not is_deprecated_api_only:
for state in proof_states.states:

View File

@@ -4,7 +4,6 @@ import bolt11
import pytest
import pytest_asyncio
from cashu.core.base import ProofSpentState
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
@@ -57,14 +56,14 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger):
await asyncio.sleep(SLEEP_TIME)
states = await wallet.check_proof_state(send_proofs)
assert all([s.state == ProofSpentState.pending for s in states.states])
assert all([s.pending for s in states.states])
settle_invoice(preimage=preimage)
await asyncio.sleep(SLEEP_TIME)
states = await wallet.check_proof_state(send_proofs)
assert all([s.state == ProofSpentState.spent for s in states.states])
assert all([s.spent for s in states.states])
@pytest.mark.asyncio
@@ -97,11 +96,11 @@ async def test_regtest_failed_quote(wallet: Wallet, ledger: Ledger):
await asyncio.sleep(SLEEP_TIME)
states = await wallet.check_proof_state(send_proofs)
assert all([s.state == ProofSpentState.pending for s in states.states])
assert all([s.pending for s in states.states])
cancel_invoice(preimage_hash=preimage_hash)
await asyncio.sleep(SLEEP_TIME)
states = await wallet.check_proof_state(send_proofs)
assert all([s.state == ProofSpentState.unspent for s in states.states])
assert all([s.unspent for s in states.states])

View File

@@ -59,19 +59,24 @@ async def test_regtest_pay_mpp(wallet: Wallet, ledger: Ledger):
fee_reserve_sat=quote.fee_reserve,
quote_id=quote.quote,
)
def mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]):
asyncio.run(_mint_pay_mpp(invoice, amount, proofs))
# call pay_mpp twice in parallel to pay the full invoice
t1 = threading.Thread(target=mint_pay_mpp, args=(invoice_payment_request, 32, proofs1))
t2 = threading.Thread(target=partial_pay_real_invoice, args=(invoice_payment_request, 32, 1))
t1 = threading.Thread(
target=mint_pay_mpp, args=(invoice_payment_request, 32, proofs1)
)
t2 = threading.Thread(
target=partial_pay_real_invoice, args=(invoice_payment_request, 32, 1)
)
t1.start()
t2.start()
t1.join()
t2.join()
assert wallet.balance <= 256 - 32
assert wallet.balance == 64
@pytest.mark.asyncio

View File

@@ -3,7 +3,7 @@ import asyncio
import pytest
import pytest_asyncio
from cashu.core.base import Method, MintQuoteState, ProofSpentState, ProofState
from cashu.core.base import Method, MintQuoteState, ProofState
from cashu.core.json_rpc.base import JSONRPCNotficationParams
from cashu.core.nuts import WEBSOCKETS_NUT
from cashu.core.settings import settings
@@ -54,13 +54,10 @@ async def test_wallet_subscription_mint(wallet: Wallet):
assert triggered
assert len(msg_stack) == 3
assert msg_stack[0].payload["paid"] is False
assert msg_stack[0].payload["state"] == MintQuoteState.unpaid.value
assert msg_stack[1].payload["paid"] is True
assert msg_stack[1].payload["state"] == MintQuoteState.paid.value
assert msg_stack[2].payload["paid"] is True
assert msg_stack[2].payload["state"] == MintQuoteState.issued.value
@@ -100,16 +97,16 @@ async def test_wallet_subscription_swap(wallet: Wallet):
pending_stack = msg_stack[:n_subscriptions]
for msg in pending_stack:
proof_state = ProofState.parse_obj(msg.payload)
assert proof_state.state == ProofSpentState.unspent
assert proof_state.unspent
# the second one is the PENDING state
spent_stack = msg_stack[n_subscriptions : n_subscriptions * 2]
for msg in spent_stack:
proof_state = ProofState.parse_obj(msg.payload)
assert proof_state.state == ProofSpentState.pending
assert proof_state.pending
# the third one is the SPENT state
spent_stack = msg_stack[n_subscriptions * 2 :]
for msg in spent_stack:
proof_state = ProofState.parse_obj(msg.payload)
assert proof_state.state == ProofSpentState.spent
assert proof_state.spent