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: def kind(self) -> JSONRPCSubscriptionKinds:
return JSONRPCSubscriptionKinds.PROOF_STATE 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): class HTLCWitness(BaseModel):
preimage: Optional[str] = None preimage: Optional[str] = None
@@ -290,7 +302,6 @@ class MeltQuote(LedgerEvent):
unit: str unit: str
amount: int amount: int
fee_reserve: int fee_reserve: int
paid: bool
state: MeltQuoteState state: MeltQuoteState
created_time: Union[int, None] = None created_time: Union[int, None] = None
paid_time: Union[int, None] = None paid_time: Union[int, None] = None
@@ -325,7 +336,6 @@ class MeltQuote(LedgerEvent):
unit=row["unit"], unit=row["unit"],
amount=row["amount"], amount=row["amount"],
fee_reserve=row["fee_reserve"], fee_reserve=row["fee_reserve"],
paid=row["paid"],
state=MeltQuoteState[row["state"]], state=MeltQuoteState[row["state"]],
created_time=created_time, created_time=created_time,
paid_time=paid_time, paid_time=paid_time,
@@ -344,17 +354,34 @@ class MeltQuote(LedgerEvent):
def kind(self) -> JSONRPCSubscriptionKinds: def kind(self) -> JSONRPCSubscriptionKinds:
return JSONRPCSubscriptionKinds.BOLT11_MELT_QUOTE 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 # 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): def __setattr__(self, name, value):
# an unpaid quote can only be set to pending or paid # 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]: if value not in [MeltQuoteState.pending, MeltQuoteState.paid]:
raise Exception( raise Exception(
f"Cannot change state of an unpaid melt quote to {value}." f"Cannot change state of an unpaid melt quote to {value}."
) )
# a paid quote can not be changed # 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.") 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) super().__setattr__(name, value)
@@ -375,8 +402,6 @@ class MintQuote(LedgerEvent):
checking_id: str checking_id: str
unit: str unit: str
amount: int amount: int
paid: bool
issued: bool
state: MintQuoteState state: MintQuoteState
created_time: Union[int, None] = None created_time: Union[int, None] = None
paid_time: Union[int, None] = None paid_time: Union[int, None] = None
@@ -401,8 +426,6 @@ class MintQuote(LedgerEvent):
checking_id=row["checking_id"], checking_id=row["checking_id"],
unit=row["unit"], unit=row["unit"],
amount=row["amount"], amount=row["amount"],
paid=row["paid"],
issued=row["issued"],
state=MintQuoteState[row["state"]], state=MintQuoteState[row["state"]],
created_time=created_time, created_time=created_time,
paid_time=paid_time, paid_time=paid_time,
@@ -417,24 +440,45 @@ class MintQuote(LedgerEvent):
def kind(self) -> JSONRPCSubscriptionKinds: def kind(self) -> JSONRPCSubscriptionKinds:
return JSONRPCSubscriptionKinds.BOLT11_MINT_QUOTE 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): def __setattr__(self, name, value):
# un unpaid quote can only be set to paid # 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: if value != MintQuoteState.paid:
raise Exception( raise Exception(
f"Cannot change state of an unpaid mint quote to {value}." f"Cannot change state of an unpaid mint quote to {value}."
) )
# a paid quote can only be set to pending or issued # 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: if value != MintQuoteState.pending and value != MintQuoteState.issued:
raise Exception(f"Cannot change state of a paid mint quote to {value}.") raise Exception(f"Cannot change state of a paid mint quote to {value}.")
# a pending quote can only be set to paid or issued # 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]: if value not in [MintQuoteState.paid, MintQuoteState.issued]:
raise Exception("Cannot change state of a pending mint quote.") raise Exception("Cannot change state of a pending mint quote.")
# an issued quote cannot be changed # 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.") 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) super().__setattr__(name, value)

View File

@@ -213,7 +213,7 @@ class PostMeltQuoteResponse(BaseModel):
fee_reserve: int # input fee reserve fee_reserve: int # input fee reserve
paid: Optional[ paid: Optional[
bool 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 state: Optional[str] # state of the quote
expiry: Optional[int] # expiry of the quote expiry: Optional[int] # expiry of the quote
payment_preimage: Optional[str] = None # payment preimage payment_preimage: Optional[str] = None # payment preimage
@@ -224,6 +224,8 @@ class PostMeltQuoteResponse(BaseModel):
to_dict = melt_quote.dict() to_dict = melt_quote.dict()
# turn state into string # turn state into string
to_dict["state"] = melt_quote.state.value to_dict["state"] = melt_quote.state.value
# add deprecated "paid" field
to_dict["paid"] = melt_quote.paid
return PostMeltQuoteResponse.parse_obj(to_dict) 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_outgoing_payment: Optional[float] = Field(default=3.0)
fakewallet_delay_incoming_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_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): class MintInformation(CashuSettings):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
# type: ignore
import secrets import secrets
from typing import AsyncGenerator, Optional from typing import AsyncGenerator, Dict, Optional, Union
import httpx import httpx
from pydantic import BaseModel
from ..core.base import Amount, MeltQuote, Unit from ..core.base import Amount, MeltQuote, Unit
from ..core.models import PostMeltQuoteRequest from ..core.models import PostMeltQuoteRequest
@@ -12,6 +12,7 @@ from .base import (
LightningBackend, LightningBackend,
PaymentQuoteResponse, PaymentQuoteResponse,
PaymentResponse, PaymentResponse,
PaymentResult,
PaymentStatus, PaymentStatus,
StatusResponse, StatusResponse,
) )
@@ -19,13 +20,92 @@ from .base import (
USDT = "USDT" 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): class StrikeWallet(LightningBackend):
"""https://docs.strike.me/api/""" """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 supports_description: bool = False
currency_map = {Unit.sat: "BTC", Unit.usd: "USD", Unit.eur: "EUR"} 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): def __init__(self, unit: Unit, **kwargs):
self.assert_unit_supported(unit) self.assert_unit_supported(unit)
self.unit = unit self.unit = unit
@@ -98,45 +178,29 @@ class StrikeWallet(LightningBackend):
payload = { payload = {
"correlationId": secrets.token_hex(16), "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}, "amount": {"amount": amount.to_float_string(), "currency": self.currency},
} }
try: try:
r = await self.client.post(url=f"{self.endpoint}/v1/invoices", json=payload) r = await self.client.post(url=f"{self.endpoint}/v1/invoices", json=payload)
r.raise_for_status() r.raise_for_status()
except Exception: except Exception:
return InvoiceResponse( return InvoiceResponse(ok=False, error_message=r.json()["detail"])
paid=False,
checking_id=None,
payment_request=None,
error_message=r.json()["detail"],
)
quote = r.json() invoice = StrikeCreateInvoiceResponse.parse_obj(r.json())
invoice_id = quote.get("invoiceId")
try: try:
payload = {"descriptionHash": secrets.token_hex(32)} payload = {"descriptionHash": secrets.token_hex(32)}
r2 = await self.client.post( 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: except Exception:
return InvoiceResponse( return InvoiceResponse(ok=False, error_message=r.json()["detail"])
paid=False,
checking_id=None,
payment_request=None,
error_message=r.json()["detail"],
)
data2 = r2.json() quote = InvoiceQuoteResponse.parse_obj(r2.json())
payment_request = data2.get("lnInvoice")
assert payment_request, "Did not receive an invoice"
checking_id = invoice_id
return InvoiceResponse( return InvoiceResponse(
ok=True, ok=True, checking_id=invoice.invoiceId, payment_request=quote.lnInvoice
checking_id=checking_id,
payment_request=payment_request,
error_message=None,
) )
async def get_payment_quote( async def get_payment_quote(
@@ -153,13 +217,18 @@ class StrikeWallet(LightningBackend):
except Exception: except Exception:
error_message = r.json()["data"]["message"] error_message = r.json()["data"]["message"]
raise Exception(error_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( quote = PaymentQuoteResponse(
amount=amount, amount=amount,
checking_id=data.get("paymentQuoteId"), checking_id=strike_quote.paymentQuoteId,
fee=Amount(self.unit, 0), fee=Amount(self.unit, fee),
) )
return quote return quote
@@ -176,49 +245,42 @@ class StrikeWallet(LightningBackend):
except Exception: except Exception:
error_message = r.json()["data"]["message"] error_message = r.json()["data"]["message"]
return PaymentResponse( return PaymentResponse(
ok=None, result=PaymentResult.FAILED, error_message=error_message
checking_id=None,
fee=None,
preimage=None,
error_message=error_message,
) )
data = r.json() payment = StrikePaymentResponse.parse_obj(r.json())
states = {"PENDING": None, "COMPLETED": True, "FAILED": False} fee = self.fee_int(payment)
if states[data.get("state")]: return PaymentResponse(
return PaymentResponse( result=PAYMENT_RESULT_MAP[payment.state],
ok=True, checking_id=None, fee=None, preimage=None, error_message=None checking_id=payment.paymentId,
) fee=Amount(self.unit, fee),
else: )
return PaymentResponse(
ok=False, checking_id=None, fee=None, preimage=None, error_message=None
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try: try:
r = await self.client.get(url=f"{self.endpoint}/v1/invoices/{checking_id}") r = await self.client.get(url=f"{self.endpoint}/v1/invoices/{checking_id}")
r.raise_for_status() r.raise_for_status()
except Exception: except Exception as e:
return PaymentStatus(paid=None) return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e))
data = r.json() data = r.json()
states = {"PENDING": None, "UNPAID": None, "PAID": True, "CANCELLED": False} return PaymentStatus(result=INVOICE_RESULT_MAP[data.get("state")])
return PaymentStatus(paid=states[data["state"]])
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try: try:
r = await self.client.get(url=f"{self.endpoint}/v1/payments/{checking_id}") r = await self.client.get(url=f"{self.endpoint}/v1/payments/{checking_id}")
r.raise_for_status() r.raise_for_status()
except Exception: payment = StrikePaymentResponse.parse_obj(r.json())
return PaymentStatus(paid=None) fee = self.fee_int(payment)
data = r.json() return PaymentStatus(
if "paid" not in data and "details" not in data: result=PAYMENT_RESULT_MAP[payment.state],
return PaymentStatus(paid=None) 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( async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: # type: ignore
paid=data["paid"],
fee_msat=data["details"]["fee"],
preimage=data["preimage"],
)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
raise NotImplementedError("paid_invoices_stream not implemented") raise NotImplementedError("paid_invoices_stream not implemented")

View File

@@ -201,16 +201,6 @@ class LedgerCrud(ABC):
) -> None: ) -> None:
... ...
# @abstractmethod
# async def update_mint_quote_paid(
# self,
# *,
# quote_id: str,
# paid: bool,
# db: Database,
# conn: Optional[Connection] = None,
# ) -> None: ...
@abstractmethod @abstractmethod
async def store_melt_quote( async def store_melt_quote(
self, self,
@@ -233,6 +223,16 @@ class LedgerCrud(ABC):
) -> Optional[MeltQuote]: ) -> Optional[MeltQuote]:
... ...
@abstractmethod
async def get_melt_quote_by_request(
self,
*,
request: str,
db: Database,
conn: Optional[Connection] = None,
) -> Optional[MeltQuote]:
...
@abstractmethod @abstractmethod
async def update_melt_quote( async def update_melt_quote(
self, self,
@@ -433,8 +433,8 @@ class LedgerCrudSqlite(LedgerCrud):
await (conn or db).execute( await (conn or db).execute(
f""" f"""
INSERT INTO {db.table_with_schema('mint_quotes')} INSERT INTO {db.table_with_schema('mint_quotes')}
(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, :paid, :state, :created_time, :paid_time) VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :issued, :state, :created_time, :paid_time)
""", """,
{ {
"quote": quote.quote, "quote": quote.quote,
@@ -443,8 +443,7 @@ class LedgerCrudSqlite(LedgerCrud):
"checking_id": quote.checking_id, "checking_id": quote.checking_id,
"unit": quote.unit, "unit": quote.unit,
"amount": quote.amount, "amount": quote.amount,
"issued": quote.issued, "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)
"paid": quote.paid,
"state": quote.state.name, "state": quote.state.name,
"created_time": db.to_timestamp( "created_time": db.to_timestamp(
db.timestamp_from_seconds(quote.created_time) or "" db.timestamp_from_seconds(quote.created_time) or ""
@@ -513,10 +512,8 @@ class LedgerCrudSqlite(LedgerCrud):
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
await (conn or db).execute( 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, "state": quote.state.name,
"paid_time": db.to_timestamp( "paid_time": db.to_timestamp(
db.timestamp_from_seconds(quote.paid_time) or "" 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( async def store_melt_quote(
self, self,
*, *,
@@ -552,8 +532,8 @@ class LedgerCrudSqlite(LedgerCrud):
await (conn or db).execute( await (conn or db).execute(
f""" f"""
INSERT INTO {db.table_with_schema('melt_quotes')} 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) (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, :paid, :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, "quote": quote.quote,
@@ -563,7 +543,6 @@ class LedgerCrudSqlite(LedgerCrud):
"unit": quote.unit, "unit": quote.unit,
"amount": quote.amount, "amount": quote.amount,
"fee_reserve": quote.fee_reserve or 0, "fee_reserve": quote.fee_reserve or 0,
"paid": quote.paid,
"state": quote.state.name, "state": quote.state.name,
"created_time": db.to_timestamp( "created_time": db.to_timestamp(
db.timestamp_from_seconds(quote.created_time) or "" db.timestamp_from_seconds(quote.created_time) or ""
@@ -610,8 +589,22 @@ class LedgerCrudSqlite(LedgerCrud):
""", """,
values, values,
) )
if row is None: return MeltQuote.from_row(row) if row else None
return 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 return MeltQuote.from_row(row) if row else None
async def update_melt_quote( async def update_melt_quote(
@@ -623,10 +616,9 @@ class LedgerCrudSqlite(LedgerCrud):
) -> None: ) -> None:
await (conn or db).execute( await (conn or db).execute(
f""" 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, "state": quote.state.name,
"fee_paid": quote.fee_paid, "fee_paid": quote.fee_paid,
"paid_time": db.to_timestamp( "paid_time": db.to_timestamp(
@@ -637,6 +629,7 @@ class LedgerCrudSqlite(LedgerCrud):
if quote.change if quote.change
else None, else None,
"quote": quote.quote, "quote": quote.quote,
"checking_id": quote.checking_id,
}, },
) )
@@ -678,7 +671,7 @@ class LedgerCrudSqlite(LedgerCrud):
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> int: ) -> int:
row = await (conn or db).fetchone( row: List = await (conn or db).fetchone(
f""" f"""
SELECT * from {db.table_with_schema('balance')} SELECT * from {db.table_with_schema('balance')}
""" """

View File

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

View File

@@ -50,6 +50,8 @@ from ..lightning.base import (
InvoiceResponse, InvoiceResponse,
LightningBackend, LightningBackend,
PaymentQuoteResponse, PaymentQuoteResponse,
PaymentResponse,
PaymentResult,
PaymentStatus, PaymentStatus,
) )
from ..mint.crud import LedgerCrudSqlite from ..mint.crud import LedgerCrudSqlite
@@ -123,12 +125,13 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
) )
status = await self.backends[method][unit].status() status = await self.backends[method][unit].status()
if status.error_message: if status.error_message:
logger.warning( logger.error(
"The backend for" "The backend for"
f" {self.backends[method][unit].__class__.__name__} isn't" f" {self.backends[method][unit].__class__.__name__} isn't"
f" working properly: '{status.error_message}'", f" working properly: '{status.error_message}'",
RuntimeWarning, RuntimeWarning,
) )
exit(1)
logger.info(f"Backend balance: {status.balance} {unit.name}") logger.info(f"Backend balance: {status.balance} {unit.name}")
logger.info(f"Data dir: {settings.cashu_dir}") logger.info(f"Data dir: {settings.cashu_dir}")
@@ -148,40 +151,10 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
) )
if not melt_quotes: if not melt_quotes:
return return
logger.info("Checking pending melt quotes")
for quote in melt_quotes: for quote in melt_quotes:
# get pending proofs for quote quote = await self.get_melt_quote(quote_id=quote.quote, purge_unknown=True)
pending_proofs = await self.crud.get_pending_proofs_for_quote( logger.info(f"Melt quote {quote.quote} state: {quote.state}")
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
# ------- KEYS ------- # ------- KEYS -------
@@ -447,8 +420,6 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
checking_id=invoice_response.checking_id, checking_id=invoice_response.checking_id,
unit=quote_request.unit, unit=quote_request.unit,
amount=quote_request.amount, amount=quote_request.amount,
issued=False,
paid=False,
state=MintQuoteState.unpaid, state=MintQuoteState.unpaid,
created_time=int(time.time()), created_time=int(time.time()),
expiry=expiry, expiry=expiry,
@@ -476,14 +447,14 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
unit, method = self._verify_and_get_unit_method(quote.unit, quote.method) 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: if not quote.checking_id:
raise CashuError("quote has no checking id") raise CashuError("quote has no checking id")
logger.trace(f"Lightning: checking invoice {quote.checking_id}") logger.trace(f"Lightning: checking invoice {quote.checking_id}")
status: PaymentStatus = await self.backends[method][ status: PaymentStatus = await self.backends[method][
unit unit
].get_invoice_status(quote.checking_id) ].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 # change state to paid in one transaction, it could have been marked paid
# by the invoice listener in the mean time # by the invoice listener in the mean time
async with self.db.get_connection( async with self.db.get_connection(
@@ -495,9 +466,8 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
) )
if not quote: if not quote:
raise Exception("quote not found") raise Exception("quote not found")
if quote.state == MintQuoteState.unpaid: if quote.unpaid:
logger.trace(f"Setting quote {quote_id} as paid") logger.trace(f"Setting quote {quote_id} as paid")
quote.paid = True
quote.state = MintQuoteState.paid quote.state = MintQuoteState.paid
quote.paid_time = int(time.time()) quote.paid_time = int(time.time())
await self.crud.update_mint_quote( await self.crud.update_mint_quote(
@@ -537,11 +507,11 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
output_unit = self.keysets[outputs[0].id].unit output_unit = self.keysets[outputs[0].id].unit
quote = await self.get_mint_quote(quote_id) quote = await self.get_mint_quote(quote_id)
if quote.state == MintQuoteState.pending: if quote.pending:
raise TransactionError("Mint quote already pending.") raise TransactionError("Mint quote already pending.")
if quote.state == MintQuoteState.issued: if quote.issued:
raise TransactionError("Mint quote already issued.") raise TransactionError("Mint quote already issued.")
if not quote.state == MintQuoteState.paid: if not quote.paid:
raise QuoteNotPaidError() raise QuoteNotPaidError()
previous_state = quote.state previous_state = quote.state
await self.db_write._set_mint_quote_pending(quote_id=quote_id) 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 quote_id=quote_id, state=previous_state
) )
raise e raise e
await self.db_write._unset_mint_quote_pending( await self.db_write._unset_mint_quote_pending(
quote_id=quote_id, state=MintQuoteState.issued quote_id=quote_id, state=MintQuoteState.issued
) )
@@ -585,10 +554,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
raise TransactionError("mint quote already paid") raise TransactionError("mint quote already paid")
if mint_quote.issued: if mint_quote.issued:
raise TransactionError("mint quote already issued") raise TransactionError("mint quote already issued")
if not mint_quote.unpaid:
if mint_quote.state == MintQuoteState.issued:
raise TransactionError("mint quote already issued")
if mint_quote.state != MintQuoteState.unpaid:
raise TransactionError("mint quote is not unpaid") raise TransactionError("mint quote is not unpaid")
if not mint_quote.checking_id: 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) mint_quote = await self.crud.get_mint_quote(request=request, db=self.db)
if mint_quote: if mint_quote:
payment_quote = self.create_internal_melt_quote(mint_quote, melt_quote) payment_quote = self.create_internal_melt_quote(mint_quote, melt_quote)
else: else:
# not internal # not internal
# verify that the backend supports mpp if the quote request has an amount # 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, checking_id=payment_quote.checking_id,
unit=unit.name, unit=unit.name,
amount=payment_quote.amount.to(unit).amount, amount=payment_quote.amount.to(unit).amount,
paid=False,
state=MeltQuoteState.unpaid, state=MeltQuoteState.unpaid,
fee_reserve=payment_quote.fee.to(unit).amount, fee_reserve=payment_quote.fee.to(unit).amount,
created_time=int(time.time()), created_time=int(time.time()),
@@ -712,20 +676,22 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
quote=quote.quote, quote=quote.quote,
amount=quote.amount, amount=quote.amount,
fee_reserve=quote.fee_reserve, fee_reserve=quote.fee_reserve,
paid=quote.paid, paid=quote.paid, # deprecated
state=quote.state.value, state=quote.state.value,
expiry=quote.expiry, 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. """Returns a melt quote.
If melt quote is not paid yet and no internal mint quote is associated with it, If the melt quote is pending, checks status of the payment with the backend.
checks with the backend for the state of the payment request. If the backend - If settled, sets the quote as paid and invalidates pending proofs (commit).
says that the quote has been paid, updates the melt quote in the database. - 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: Args:
quote_id (str): ID of the melt quote. quote_id (str): ID of the melt quote.
purge_unknown (bool, optional): Rollback unknown payment states to unpaid. Defaults to False.
Raises: Raises:
Exception: Quote not found. 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 # we only check the state with the backend if there is no associated internal
# mint quote for this melt quote # 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 request=melt_quote.request, db=self.db
) )
if not melt_quote.paid and not mint_quote: if melt_quote.pending and not is_internal:
logger.trace( logger.debug(
"Lightning: checking outgoing Lightning payment" "Lightning: checking outgoing Lightning payment"
f" {melt_quote.checking_id}" f" {melt_quote.checking_id}"
) )
status: PaymentStatus = await self.backends[method][ status: PaymentStatus = await self.backends[method][
unit unit
].get_payment_status(melt_quote.checking_id) ].get_payment_status(melt_quote.checking_id)
if status.paid: logger.debug(f"State: {status.result}")
logger.trace(f"Setting quote {quote_id} as paid") if status.settled:
melt_quote.paid = True logger.debug(f"Setting quote {quote_id} as paid")
melt_quote.state = MeltQuoteState.paid melt_quote.state = MeltQuoteState.paid
if status.fee: if status.fee:
melt_quote.fee_paid = status.fee.to(unit).amount 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()) melt_quote.paid_time = int(time.time())
await self.crud.update_melt_quote(quote=melt_quote, db=self.db) await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
await self.events.submit(melt_quote) 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 return melt_quote
@@ -798,8 +778,6 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
# we settle the transaction internally # we settle the transaction internally
if melt_quote.paid: if melt_quote.paid:
raise TransactionError("melt quote already paid") raise TransactionError("melt quote already paid")
if melt_quote.state != MeltQuoteState.unpaid:
raise TransactionError("melt quote already paid")
# verify amounts from bolt11 invoice # verify amounts from bolt11 invoice
bolt11_request = melt_quote.request 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.fee_paid = 0 # no internal fees
melt_quote.paid = True
melt_quote.state = MeltQuoteState.paid melt_quote.state = MeltQuoteState.paid
melt_quote.paid_time = int(time.time()) melt_quote.paid_time = int(time.time())
mint_quote.paid = True
mint_quote.state = MintQuoteState.paid mint_quote.state = MintQuoteState.paid
mint_quote.paid_time = melt_quote.paid_time 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 # get melt quote and check if it was already paid
melt_quote = await self.get_melt_quote(quote_id=quote) 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( unit, method = self._verify_and_get_unit_method(
melt_quote.unit, melt_quote.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 # make sure that the outputs (for fee return) are in the same unit as the quote
if outputs: if outputs:
# _verify_outputs checks if all outputs have the same unit # _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( await self.db_write._verify_spent_proofs_and_set_pending(
proofs, quote_id=melt_quote.quote proofs, quote_id=melt_quote.quote
) )
previous_state = melt_quote.state
melt_quote = await self.db_write._set_melt_quote_pending(melt_quote)
try: 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) melt_quote = await self.melt_mint_settle_internally(melt_quote, proofs)
# quote not paid yet (not internal), pay it with the backend # 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}") logger.debug(f"Lightning: pay invoice {melt_quote.request}")
payment = await self.backends[method][unit].pay_invoice( try:
melt_quote, melt_quote.fee_reserve * 1000 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}"
) )
if payment.fee: logger.debug(
melt_quote.fee_paid = payment.fee.to( f"Melt Result: {str(payment.result)}: preimage: {payment.preimage},"
to_unit=unit, round="up" f" fee: {payment.fee.str() if payment.fee is not None else 'None'}"
).amount )
if payment.preimage: if (
melt_quote.payment_preimage = payment.preimage payment.checking_id
# set quote as paid and payment.checking_id != melt_quote.checking_id
melt_quote.paid = True ):
melt_quote.state = MeltQuoteState.paid logger.warning(
melt_quote.paid_time = int(time.time()) 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 # melt successful, invalidate proofs
await self._invalidate_proofs(proofs=proofs, quote_id=melt_quote.quote) 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 # prepare change to compensate wallet for overpaid fees
return_promises: List[BlindedSignature] = [] 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.crud.update_melt_quote(quote=melt_quote, db=self.db)
await self.events.submit(melt_quote) await self.events.submit(melt_quote)
except Exception as e: return PostMeltQuoteResponse.from_melt_quote(melt_quote)
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) 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( async def swap(
self, self,

View File

@@ -1,4 +1,5 @@
import copy import copy
from typing import Dict, List
from ..core.base import MintKeyset, Proof from ..core.base import MintKeyset, Proof
from ..core.crypto.keys import derive_keyset_id, derive_keyset_id_deprecated 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, checking_id TEXT NOT NULL,
unit TEXT NOT NULL, unit TEXT NOT NULL,
amount {db.big_int} NOT NULL, amount {db.big_int} NOT NULL,
paid BOOL NOT NULL,
issued BOOL NOT NULL, issued BOOL NOT NULL,
created_time TIMESTAMP, created_time TIMESTAMP,
paid_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( await conn.execute(
@@ -308,7 +309,6 @@ async def m011_add_quote_tables(db: Database):
unit TEXT NOT NULL, unit TEXT NOT NULL,
amount {db.big_int} NOT NULL, amount {db.big_int} NOT NULL,
fee_reserve {db.big_int}, fee_reserve {db.big_int},
paid BOOL NOT NULL,
created_time TIMESTAMP, created_time TIMESTAMP,
paid_time TIMESTAMP, paid_time TIMESTAMP,
fee_paid {db.big_int}, 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( await conn.execute(
f"INSERT INTO {db.table_with_schema('mint_quotes')} (quote, method," 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')," " 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')} " 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 # and the `paid` and `issued` column respectively
# mint quotes: # mint quotes:
async with db.connect() as conn: 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')}" f"SELECT * FROM {db.table_with_schema('mint_quotes')}"
) )
for row in rows: for row in rows:
if row["issued"]: if row.get("issued"):
state = "issued" state = "issued"
elif row["paid"]: elif row.get("paid"):
state = "paid" state = "paid"
else: else:
state = "unpaid" state = "unpaid"
@@ -804,10 +805,10 @@ async def m020_add_state_to_mint_and_melt_quotes(db: Database):
# melt quotes: # melt quotes:
async with db.connect() as conn: 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')}" f"SELECT * FROM {db.table_with_schema('melt_quotes')}"
) )
for row in rows: for row in rows2:
if row["paid"]: if row["paid"]:
state = "paid" state = "paid"
else: else:

View File

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

View File

@@ -3,7 +3,7 @@ from typing import Dict, List, Optional
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from loguru import logger from loguru import logger
from ..core.base import BlindedMessage, BlindedSignature, ProofSpentState from ..core.base import BlindedMessage, BlindedSignature
from ..core.errors import CashuError from ..core.errors import CashuError
from ..core.models import ( from ..core.models import (
CheckFeesRequest_deprecated, CheckFeesRequest_deprecated,
@@ -345,13 +345,13 @@ async def check_spendable_deprecated(
spendableList: List[bool] = [] spendableList: List[bool] = []
pendingList: List[bool] = [] pendingList: List[bool] = []
for proof_state in proofs_state: for proof_state in proofs_state:
if proof_state.state == ProofSpentState.unspent: if proof_state.unspent:
spendableList.append(True) spendableList.append(True)
pendingList.append(False) pendingList.append(False)
elif proof_state.state == ProofSpentState.spent: elif proof_state.spent:
spendableList.append(False) spendableList.append(False)
pendingList.append(False) pendingList.append(False)
elif proof_state.state == ProofSpentState.pending: elif proof_state.pending:
spendableList.append(True) spendableList.append(True)
pendingList.append(True) pendingList.append(True)
return CheckSpendableResponse_deprecated( 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}" f"Invoice callback dispatcher: quote {quote} trying to set as {MintQuoteState.paid}"
) )
# set the quote as paid # set the quote as paid
if quote.state == MintQuoteState.unpaid: if quote.unpaid:
quote.paid = True
quote.state = MintQuoteState.paid quote.state = MintQuoteState.paid
await self.crud.update_mint_quote(quote=quote, db=self.db, conn=conn) await self.crud.update_mint_quote(quote=quote, db=self.db, conn=conn)
logger.trace( logger.trace(

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ settings.mint_seed_decryption_key = ""
settings.mint_max_balance = 0 settings.mint_max_balance = 0
settings.mint_transaction_rate_limit_per_minute = 60 settings.mint_transaction_rate_limit_per_minute = 60
settings.mint_lnd_enable_mpp = True 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.mint_input_fee_ppk = 0
settings.db_connection_pool = True settings.db_connection_pool = True

View File

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

View File

@@ -2,7 +2,7 @@ import asyncio
import datetime import datetime
import os import os
import time import time
from typing import List from typing import List, Tuple
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@@ -63,7 +63,8 @@ async def test_db_tables(ledger: Ledger):
"SELECT table_name FROM information_schema.tables WHERE table_schema =" "SELECT table_name FROM information_schema.tables WHERE table_schema ="
" 'public';" " '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 = [ tables_expected = [
"dbversions", "dbversions",
"keysets", "keysets",

View File

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

View File

@@ -1,7 +1,7 @@
import pytest import pytest
import pytest_asyncio 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.core.models import PostMeltQuoteRequest
from cashu.mint.ledger import Ledger from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet 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 = wallet1.proofs.copy()
proofs_states_before_split = await wallet1.check_proof_state(proofs) proofs_states_before_split = await wallet1.check_proof_state(proofs)
assert all( assert all([s.unspent for s in proofs_states_before_split.states])
[s.state == ProofSpentState.unspent for s in proofs_states_before_split.states]
)
await ledger.db_write._verify_spent_proofs_and_set_pending(proofs) await ledger.db_write._verify_spent_proofs_and_set_pending(proofs)
proof_states = await wallet1.check_proof_state(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 assert_err(wallet1.split(wallet1.proofs, 20), "proofs are pending.")
await ledger.db_write._unset_proofs_pending(proofs) 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) await wallet1.split(proofs, 20)
proofs_states_after_split = await wallet1.check_proof_state(proofs) proofs_states_after_split = await wallet1.check_proof_state(proofs)
assert all( assert all([s.spent for s in proofs_states_after_split.states])
[s.state == ProofSpentState.spent for s in proofs_states_after_split.states]
)
@pytest.mark.asyncio @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) quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db)
assert quote is not None assert quote is not None
assert quote.quote == invoice.id assert quote.quote == invoice.id
assert quote.state == MintQuoteState.unpaid assert quote.unpaid
# set pending again # set pending again
async def set_state(quote, state): 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) quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db)
assert quote is not None assert quote is not None
assert quote.quote == melt_quote.quote assert quote.quote == melt_quote.quote
assert quote.state == MeltQuoteState.unpaid assert quote.unpaid
previous_state = quote.state previous_state = quote.state
await ledger.db_write._set_melt_quote_pending(quote) await ledger.db_write._set_melt_quote_pending(quote)
quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db) quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db)
assert quote is not None assert quote is not None
assert quote.state == MeltQuoteState.pending assert quote.pending
# set unpending # set unpending
await ledger.db_write._unset_melt_quote_pending(quote, previous_state) 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) quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db)
assert quote is not None assert quote is not None
assert quote.quote == melt_quote.quote assert quote.quote == melt_quote.quote
assert quote.state == MeltQuoteState.unpaid assert quote.unpaid
# set pending # set pending
quote.state = MeltQuoteState.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 assert invoice is not None
quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db) quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db)
assert quote is not None 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 # pay_if_regtest pays on regtest, get_mint_quote pays on FakeWallet
await pay_if_regtest(invoice.bolt11) 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) quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db)
assert quote is not None assert quote is not None
assert quote.state == MintQuoteState.paid assert quote.paid
previous_state = MintQuoteState.paid previous_state = MintQuoteState.paid
await ledger.db_write._set_mint_quote_pending(quote.quote) await ledger.db_write._set_mint_quote_pending(quote.quote)
quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db) quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db)
assert quote is not None assert quote is not None
assert quote.state == MintQuoteState.pending assert quote.pending
# try to mint while pending # try to mint while pending
await assert_err(wallet1.mint(128, id=invoice.id), "Mint quote already 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) quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db)
assert quote is not None assert quote is not None
assert quote.state == previous_state assert quote.state == previous_state
assert quote.state == MintQuoteState.paid assert quote.paid
# # set paid and mint again # # set paid and mint again
# quote.state = MintQuoteState.paid # quote.state = MintQuoteState.paid
@@ -254,7 +250,7 @@ async def test_mint_quote_set_pending(wallet1: Wallet, ledger: Ledger):
# check if quote is issued # check if quote is issued
quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db) quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db)
assert quote is not None assert quote is not None
assert quote.state == MintQuoteState.issued assert quote.issued
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -5,10 +5,11 @@ import bolt11
import pytest import pytest
import pytest_asyncio 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.crypto.aes import AESCipher
from cashu.core.db import Database from cashu.core.db import Database
from cashu.core.settings import settings from cashu.core.settings import settings
from cashu.lightning.base import PaymentResult
from cashu.mint.crud import LedgerCrudSqlite from cashu.mint.crud import LedgerCrudSqlite
from cashu.mint.ledger import Ledger from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet
@@ -143,8 +144,7 @@ async def create_pending_melts(
request="asdasd", request="asdasd",
checking_id=check_id, checking_id=check_id,
unit="sat", unit="sat",
paid=False, state=MeltQuoteState.pending,
state=MeltQuoteState.unpaid,
amount=100, amount=100,
fee_reserve=1, 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.""" after the startup routine determines that the associated melt quote was paid."""
pending_proof, quote = await create_pending_melts(ledger) pending_proof, quote = await create_pending_melts(ledger)
states = await ledger.db_read.get_proofs_states([pending_proof.Y]) states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].state == ProofSpentState.pending assert states[0].pending
settings.fakewallet_payment_state = True settings.fakewallet_payment_state = PaymentResult.SETTLED.name
# run startup routinge # run startup routinge
await ledger.startup_ledger() await ledger.startup_ledger()
@@ -186,7 +186,7 @@ async def test_startup_fakewallet_pending_quote_success(ledger: Ledger):
# expect that proofs are spent # expect that proofs are spent
states = await ledger.db_read.get_proofs_states([pending_proof.Y]) states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].state == ProofSpentState.spent assert states[0].spent
@pytest.mark.asyncio @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) pending_proof, quote = await create_pending_melts(ledger)
states = await ledger.db_read.get_proofs_states([pending_proof.Y]) states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].state == ProofSpentState.pending assert states[0].pending
settings.fakewallet_payment_state = False settings.fakewallet_payment_state = PaymentResult.FAILED.name
# run startup routinge # run startup routinge
await ledger.startup_ledger() await ledger.startup_ledger()
@@ -212,7 +212,7 @@ async def test_startup_fakewallet_pending_quote_failure(ledger: Ledger):
# expect that proofs are unspent # expect that proofs are unspent
states = await ledger.db_read.get_proofs_states([pending_proof.Y]) states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].state == ProofSpentState.unspent assert states[0].unspent
@pytest.mark.asyncio @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): async def test_startup_fakewallet_pending_quote_pending(ledger: Ledger):
pending_proof, quote = await create_pending_melts(ledger) pending_proof, quote = await create_pending_melts(ledger)
states = await ledger.db_read.get_proofs_states([pending_proof.Y]) states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].state == ProofSpentState.pending assert states[0].pending
settings.fakewallet_payment_state = None settings.fakewallet_payment_state = PaymentResult.PENDING.name
# run startup routinge # run startup routinge
await ledger.startup_ledger() await ledger.startup_ledger()
@@ -233,7 +233,28 @@ async def test_startup_fakewallet_pending_quote_pending(ledger: Ledger):
# expect that proofs are still pending # expect that proofs are still pending
states = await ledger.db_read.get_proofs_states([pending_proof.Y]) 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 @pytest.mark.asyncio
@@ -262,7 +283,6 @@ async def test_startup_regtest_pending_quote_pending(wallet: Wallet, ledger: Led
) )
) )
await asyncio.sleep(SLEEP_TIME) await asyncio.sleep(SLEEP_TIME)
# settle_invoice(preimage=preimage)
# run startup routinge # run startup routinge
await ledger.startup_ledger() 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 # expect that proofs are still pending
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) 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 # only now settle the invoice
settle_invoice(preimage=preimage) 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) await asyncio.sleep(SLEEP_TIME)
# expect that proofs are pending # expect that proofs are pending
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) 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) settle_invoice(preimage=preimage)
await asyncio.sleep(SLEEP_TIME) 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 # expect that proofs are spent
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) 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 @pytest.mark.asyncio
@@ -360,7 +380,7 @@ async def test_startup_regtest_pending_quote_failure(wallet: Wallet, ledger: Led
# expect that proofs are pending # expect that proofs are pending
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) 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) cancel_invoice(preimage_hash=preimage_hash)
await asyncio.sleep(SLEEP_TIME) 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 # expect that proofs are unspent
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) 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", unit="sat",
amount=100, amount=100,
fee_reserve=12, fee_reserve=12,
paid=False,
state=MeltQuoteState.unpaid, state=MeltQuoteState.unpaid,
) )
payment = await blink.pay_invoice(quote, 1000) payment = await blink.pay_invoice(quote, 1000)
assert payment.ok assert payment.settled
assert payment.fee assert payment.fee
assert payment.fee.amount == 10 assert payment.fee.amount == 10
assert payment.error_message is None assert payment.error_message is None
@@ -131,11 +130,10 @@ async def test_blink_pay_invoice_failure():
unit="sat", unit="sat",
amount=100, amount=100,
fee_reserve=12, fee_reserve=12,
paid=False,
state=MeltQuoteState.unpaid, state=MeltQuoteState.unpaid,
) )
payment = await blink.pay_invoice(quote, 1000) payment = await blink.pay_invoice(quote, 1000)
assert not payment.ok assert not payment.settled
assert payment.fee is None assert payment.fee is None
assert payment.error_message assert payment.error_message
assert "This is the error" in 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)) respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
status = await blink.get_invoice_status("123") status = await blink.get_invoice_status("123")
assert status.paid assert status.settled
@respx.mock @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)) respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
status = await blink.get_payment_status(payment_request) status = await blink.get_payment_status(payment_request)
assert status.paid assert status.settled
assert status.fee assert status.fee
assert status.fee.amount == 10 assert status.fee.amount == 10
assert status.preimage == "123" 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
import pytest_asyncio 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.helpers import sum_proofs
from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest
from cashu.mint.ledger import Ledger from cashu.mint.ledger import Ledger
@@ -38,9 +38,8 @@ async def wallet1(ledger: Ledger):
async def test_melt_internal(wallet1: Wallet, ledger: Ledger): async def test_melt_internal(wallet1: Wallet, ledger: Ledger):
# mint twice so we have enough to pay the second invoice back # mint twice so we have enough to pay the second invoice back
invoice = await wallet1.request_mint(128) 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 wallet1.mint(128, id=invoice.id)
await pay_if_regtest(invoice.bolt11)
assert wallet1.balance == 128 assert wallet1.balance == 128
# create a mint quote so that we can melt to it internally # 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) 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 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) keep_proofs, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 64)
await ledger.melt(proofs=send_proofs, quote=melt_quote.quote) await ledger.melt(proofs=send_proofs, quote=melt_quote.quote)
melt_quote_post_payment = await ledger.get_melt_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.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.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) 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 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" assert not melt_quote.paid, "melt quote should not be paid"
await ledger.melt(proofs=send_proofs, quote=melt_quote.quote) await ledger.melt(proofs=send_proofs, quote=melt_quote.quote)
melt_quote_post_payment = await ledger.get_melt_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.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.asyncio
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet") @pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
async def test_mint_internal(wallet1: Wallet, ledger: Ledger): async def test_mint_internal(wallet1: Wallet, ledger: Ledger):
invoice = await wallet1.request_mint(128) 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) mint_quote = await ledger.get_mint_quote(invoice.id)
assert mint_quote.paid, "mint quote should be paid" assert mint_quote.paid, "mint quote should be paid"
assert mint_quote.state == MintQuoteState.paid assert mint_quote.paid
output_amounts = [128] output_amounts = [128]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets( 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) 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.issued, "mint quote should be issued"
assert mint_quote_after_payment.state == MintQuoteState.issued assert mint_quote_after_payment.issued
@pytest.mark.asyncio @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): async def test_mint_external(wallet1: Wallet, ledger: Ledger):
quote = await ledger.mint_quote(PostMintQuoteRequest(amount=128, unit="sat")) quote = await ledger.mint_quote(PostMintQuoteRequest(amount=128, unit="sat"))
assert not quote.paid, "mint quote should not be paid" 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) mint_quote = await ledger.get_mint_quote(quote.quote)
assert not mint_quote.paid, "mint quote already paid" assert not mint_quote.paid, "mint quote already paid"
assert mint_quote.state == MintQuoteState.unpaid assert mint_quote.unpaid
await assert_err( await assert_err(
wallet1.mint(128, id=quote.quote), 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) mint_quote = await ledger.get_mint_quote(quote.quote)
assert mint_quote.paid, "mint quote should be paid" assert mint_quote.paid, "mint quote should be paid"
assert mint_quote.state == MintQuoteState.paid assert mint_quote.paid
output_amounts = [128] output_amounts = [128]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets( 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) await ledger.mint(outputs=outputs, quote_id=quote.quote)
mint_quote_after_payment = await ledger.get_mint_quote(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.issued, "mint quote should be issued"
assert mint_quote_after_payment.state == MintQuoteState.issued
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -1,17 +1,23 @@
import asyncio import asyncio
import bolt11
import pytest import pytest
import pytest_asyncio 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.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT from tests.conftest import SERVER_ENDPOINT
from tests.helpers import ( from tests.helpers import (
SLEEP_TIME, SLEEP_TIME,
cancel_invoice,
get_hold_invoice, get_hold_invoice,
get_real_invoice,
get_real_invoice_cln,
is_fake, is_fake,
pay_if_regtest, pay_if_regtest,
pay_real_invoice,
settle_invoice, settle_invoice,
) )
@@ -27,6 +33,243 @@ async def wallet():
yield 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.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest") @pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): 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 # expect that proofs are still pending
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) 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 # only now settle the invoice
settle_invoice(preimage=preimage) settle_invoice(preimage=preimage)
@@ -71,7 +314,7 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger):
# expect that proofs are now spent # expect that proofs are now spent
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) 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 # expect that no melt quote is pending
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(

View File

@@ -4,7 +4,7 @@ import pytest
import pytest_asyncio import pytest_asyncio
from fastapi.testclient import TestClient 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.api.app import app
from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT from tests.conftest import SERVER_ENDPOINT
@@ -29,8 +29,8 @@ async def test_invoice(wallet: Wallet):
response = client.post("/lightning/create_invoice?amount=100") response = client.post("/lightning/create_invoice?amount=100")
assert response.status_code == 200 assert response.status_code == 200
invoice_response = InvoiceResponse.parse_obj(response.json()) invoice_response = InvoiceResponse.parse_obj(response.json())
state = PaymentStatus(paid=False) state = PaymentStatus(result=PaymentResult.PENDING)
while not state.paid: while state.pending:
print("checking invoice state") print("checking invoice state")
response2 = client.get( response2 = client.get(
f"/lightning/invoice_state?payment_hash={invoice_response.checking_id}" 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"] initial_balance = response.json()["balance"]
response = client.post("/lightning/create_invoice?amount=100") response = client.post("/lightning/create_invoice?amount=100")
invoice_response = InvoiceResponse.parse_obj(response.json()) invoice_response = InvoiceResponse.parse_obj(response.json())
state = PaymentStatus(paid=False) state = PaymentStatus(result=PaymentResult.PENDING)
while not state.paid: while state.pending:
print("checking invoice state") print("checking invoice state")
response2 = client.get( response2 = client.get(
f"/lightning/invoice_state?payment_hash={invoice_response.checking_id}" 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.payment_request
assert invoice.checking_id assert invoice.checking_id
status = await wallet.get_invoice_status(invoice.checking_id) status = await wallet.get_invoice_status(invoice.checking_id)
assert status.paid assert status.settled
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -94,10 +94,10 @@ async def test_check_invoice_external(wallet: LightningWallet):
assert invoice.payment_request assert invoice.payment_request
assert invoice.checking_id assert invoice.checking_id
status = await wallet.get_invoice_status(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) await pay_if_regtest(invoice.payment_request)
status = await wallet.get_invoice_status(invoice.checking_id) status = await wallet.get_invoice_status(invoice.checking_id)
assert status.paid assert status.settled
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -115,12 +115,12 @@ async def test_pay_invoice_internal(wallet: LightningWallet):
assert invoice2.payment_request assert invoice2.payment_request
status = await wallet.pay_invoice(invoice2.payment_request) status = await wallet.pay_invoice(invoice2.payment_request)
assert status.ok assert status.settled
# check payment # check payment
assert invoice2.checking_id assert invoice2.checking_id
status = await wallet.get_payment_status(invoice2.checking_id) status = await wallet.get_payment_status(invoice2.checking_id)
assert status.paid assert status.settled
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -132,16 +132,16 @@ async def test_pay_invoice_external(wallet: LightningWallet):
assert invoice.checking_id assert invoice.checking_id
await pay_if_regtest(invoice.payment_request) await pay_if_regtest(invoice.payment_request)
status = await wallet.get_invoice_status(invoice.checking_id) status = await wallet.get_invoice_status(invoice.checking_id)
assert status.paid assert status.settled
assert wallet.available_balance >= 64 assert wallet.available_balance >= 64
# pay invoice # pay invoice
invoice_real = get_real_invoice(16) invoice_real = get_real_invoice(16)
status = await wallet.pay_invoice(invoice_real["payment_request"]) status = await wallet.pay_invoice(invoice_real["payment_request"])
assert status.ok assert status.settled
# check payment # check payment
assert status.checking_id assert status.checking_id
status = await wallet.get_payment_status(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
import pytest_asyncio 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.crypto.secp import PrivateKey, PublicKey
from cashu.core.migrations import migrate_databases from cashu.core.migrations import migrate_databases
from cashu.core.p2pk import SigFlags from cashu.core.p2pk import SigFlags
@@ -80,7 +80,7 @@ async def test_p2pk(wallet1: Wallet, wallet2: Wallet):
await wallet2.redeem(send_proofs) await wallet2.redeem(send_proofs)
proof_states = await wallet2.check_proof_state(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: if not is_deprecated_api_only:
for state in proof_states.states: for state in proof_states.states:

View File

@@ -4,7 +4,6 @@ import bolt11
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from cashu.core.base import ProofSpentState
from cashu.mint.ledger import Ledger from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT 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) await asyncio.sleep(SLEEP_TIME)
states = await wallet.check_proof_state(send_proofs) 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) settle_invoice(preimage=preimage)
await asyncio.sleep(SLEEP_TIME) await asyncio.sleep(SLEEP_TIME)
states = await wallet.check_proof_state(send_proofs) 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 @pytest.mark.asyncio
@@ -97,11 +96,11 @@ async def test_regtest_failed_quote(wallet: Wallet, ledger: Ledger):
await asyncio.sleep(SLEEP_TIME) await asyncio.sleep(SLEEP_TIME)
states = await wallet.check_proof_state(send_proofs) 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) cancel_invoice(preimage_hash=preimage_hash)
await asyncio.sleep(SLEEP_TIME) await asyncio.sleep(SLEEP_TIME)
states = await wallet.check_proof_state(send_proofs) 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, fee_reserve_sat=quote.fee_reserve,
quote_id=quote.quote, quote_id=quote.quote,
) )
def mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]): def mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]):
asyncio.run(_mint_pay_mpp(invoice, amount, proofs)) asyncio.run(_mint_pay_mpp(invoice, amount, proofs))
# call pay_mpp twice in parallel to pay the full invoice # call pay_mpp twice in parallel to pay the full invoice
t1 = threading.Thread(target=mint_pay_mpp, args=(invoice_payment_request, 32, proofs1)) t1 = threading.Thread(
t2 = threading.Thread(target=partial_pay_real_invoice, args=(invoice_payment_request, 32, 1)) 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() t1.start()
t2.start() t2.start()
t1.join() t1.join()
t2.join() t2.join()
assert wallet.balance <= 256 - 32 assert wallet.balance == 64
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -80,7 +85,7 @@ async def test_regtest_pay_mpp_incomplete_payment(wallet: Wallet, ledger: Ledger
# make sure that mpp is supported by the bolt11-sat backend # make sure that mpp is supported by the bolt11-sat backend
if not ledger.backends[Method["bolt11"]][wallet.unit].supports_mpp: if not ledger.backends[Method["bolt11"]][wallet.unit].supports_mpp:
pytest.skip("backend does not support mpp") pytest.skip("backend does not support mpp")
# This test cannot be done with CLN because we only have one mint # This test cannot be done with CLN because we only have one mint
# and CLN hates multiple partial payment requests # and CLN hates multiple partial payment requests
if isinstance(ledger.backends[Method["bolt11"]][wallet.unit], CLNRestWallet): if isinstance(ledger.backends[Method["bolt11"]][wallet.unit], CLNRestWallet):

View File

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