From 870d75b205304ac0e5e09025c3cac6c27d916430 Mon Sep 17 00:00:00 2001 From: Pavol Rusnak Date: Sun, 22 Sep 2024 15:57:17 +0200 Subject: [PATCH 1/3] feat: untangle MintMeltMethodSetting into MintMethodSetting and MeltMethodSetting (#617) + add description to MintMethodSetting --- cashu/core/models.py | 10 +++++++++- cashu/mint/features.py | 40 +++++++++++++++++++++------------------- tests/test_mint_api.py | 7 ++++--- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/cashu/core/models.py b/cashu/core/models.py index fa0b57bd..f16ea71a 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -18,7 +18,15 @@ # ------- API: INFO ------- -class MintMeltMethodSetting(BaseModel): +class MintMethodSetting(BaseModel): + method: str + unit: str + min_amount: Optional[int] = None + max_amount: Optional[int] = None + description: Optional[bool] = None + + +class MeltMethodSetting(BaseModel): method: str unit: str min_amount: Optional[int] = None diff --git a/cashu/mint/features.py b/cashu/mint/features.py index 1a3285b9..43fdec41 100644 --- a/cashu/mint/features.py +++ b/cashu/mint/features.py @@ -2,7 +2,8 @@ from ..core.base import Method from ..core.models import ( - MintMeltMethodSetting, + MeltMethodSetting, + MintMethodSetting, ) from ..core.nuts import ( DLEQ_NUT, @@ -22,32 +23,33 @@ class LedgerFeatures(SupportsBackends): def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]: - # determine all method-unit pairs - method_settings: Dict[int, List[MintMeltMethodSetting]] = {} - for nut in [MINT_NUT, MELT_NUT]: - method_settings[nut] = [] - for method, unit_dict in self.backends.items(): - for unit in unit_dict.keys(): - setting = MintMeltMethodSetting(method=method.name, unit=unit.name) - - if nut == MINT_NUT and settings.mint_max_peg_in: - setting.max_amount = settings.mint_max_peg_in - setting.min_amount = 0 - elif nut == MELT_NUT and settings.mint_max_peg_out: - setting.max_amount = settings.mint_max_peg_out - setting.min_amount = 0 - - method_settings[nut].append(setting) + mint_method_settings: List[MintMethodSetting] = [] + for method, unit_dict in self.backends.items(): + for unit in unit_dict.keys(): + mint_setting = MintMethodSetting(method=method.name, unit=unit.name) + if settings.mint_max_peg_in: + mint_setting.max_amount = settings.mint_max_peg_in + mint_setting.min_amount = 0 + mint_method_settings.append(mint_setting) + mint_setting.description = unit_dict[unit].supports_description + melt_method_settings: List[MeltMethodSetting] = [] + for method, unit_dict in self.backends.items(): + for unit in unit_dict.keys(): + melt_setting = MeltMethodSetting(method=method.name, unit=unit.name) + if settings.mint_max_peg_out: + melt_setting.max_amount = settings.mint_max_peg_out + melt_setting.min_amount = 0 + melt_method_settings.append(melt_setting) supported_dict = dict(supported=True) mint_features: Dict[int, Union[List[Any], Dict[str, Any]]] = { MINT_NUT: dict( - methods=method_settings[MINT_NUT], + methods=mint_method_settings, disabled=settings.mint_peg_out_only, ), MELT_NUT: dict( - methods=method_settings[MELT_NUT], + methods=melt_method_settings, disabled=False, ), STATE_NUT: supported_dict, diff --git a/tests/test_mint_api.py b/tests/test_mint_api.py index 494c27c5..627fd60f 100644 --- a/tests/test_mint_api.py +++ b/tests/test_mint_api.py @@ -6,7 +6,7 @@ from cashu.core.base import MeltQuoteState, MintQuoteState, ProofSpentState from cashu.core.models import ( GetInfoResponse, - MintMeltMethodSetting, + MintMethodSetting, PostCheckStateRequest, PostCheckStateResponse, PostMeltQuoteResponse, @@ -14,6 +14,7 @@ PostRestoreRequest, PostRestoreResponse, ) +from cashu.core.nuts import MINT_NUT from cashu.core.settings import settings from cashu.mint.ledger import Ledger from cashu.wallet.crud import bump_secret_derivation @@ -46,8 +47,8 @@ async def test_info(ledger: Ledger): assert response.json()["pubkey"] == ledger.pubkey.serialize().hex() info = GetInfoResponse(**response.json()) assert info.nuts - assert info.nuts[4]["disabled"] is False - setting = MintMeltMethodSetting.parse_obj(info.nuts[4]["methods"][0]) + assert info.nuts[MINT_NUT]["disabled"] is False + setting = MintMethodSetting.parse_obj(info.nuts[MINT_NUT]["methods"][0]) assert setting.method == "bolt11" assert setting.unit == "sat" From 25f0763f94993b5562c1ba83fa00df390f12d112 Mon Sep 17 00:00:00 2001 From: Pavol Rusnak Date: Tue, 24 Sep 2024 13:53:35 +0200 Subject: [PATCH 2/3] chore: run pyupgrade (#623) - use `{...}` instead of `set([...])` - do not use `class Foo(object):`, just use `class Foo:` - do not specify default flags (`"r"`) for `open()` --- cashu/core/base.py | 6 +++--- cashu/core/helpers.py | 2 +- cashu/lightning/blink.py | 2 +- cashu/lightning/clnrest.py | 4 ++-- cashu/lightning/corelightningrest.py | 2 +- cashu/lightning/fake.py | 4 ++-- cashu/lightning/lnbits.py | 2 +- cashu/lightning/lnd_grpc/lnd_grpc.py | 2 +- cashu/lightning/lndrest.py | 2 +- cashu/mint/conditions.py | 2 +- cashu/mint/verification.py | 2 +- cashu/nostr/client/cbc.py | 2 +- cashu/nostr/filter.py | 2 +- cashu/tor/tor.py | 2 +- cashu/wallet/proofs.py | 8 ++++---- cashu/wallet/v1_api.py | 2 +- 16 files changed, 23 insertions(+), 23 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index cf50f700..7f9ccee4 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -839,7 +839,7 @@ def amount(self) -> int: @property def keysets(self) -> List[str]: - return list(set([p.id for p in self.proofs])) + return list({p.id for p in self.proofs}) @property def mint(self) -> str: @@ -847,7 +847,7 @@ def mint(self) -> str: @property def mints(self) -> List[str]: - return list(set([t.mint for t in self.token if t.mint])) + return list({t.mint for t in self.token if t.mint}) @property def memo(self) -> Optional[str]: @@ -1037,7 +1037,7 @@ def proofs(self) -> List[Proof]: @property def keysets(self) -> List[str]: - return list(set([p.i.hex() for p in self.t])) + return list({p.i.hex() for p in self.t}) @classmethod def from_tokenv3(cls, tokenv3: TokenV3): diff --git a/cashu/core/helpers.py b/cashu/core/helpers.py index f3f3f0ff..80a6eccd 100644 --- a/cashu/core/helpers.py +++ b/cashu/core/helpers.py @@ -10,7 +10,7 @@ def amount_summary(proofs: List[Proof], unit: Unit) -> str: amounts_we_have = [ (amount, len([p for p in proofs if p.amount == amount])) - for amount in set([p.amount for p in proofs]) + for amount in {p.amount for p in proofs} ] amounts_we_have.sort(key=lambda x: x[0]) return ( diff --git a/cashu/lightning/blink.py b/cashu/lightning/blink.py index 1acb8a51..3f05130a 100644 --- a/cashu/lightning/blink.py +++ b/cashu/lightning/blink.py @@ -46,7 +46,7 @@ class BlinkWallet(LightningBackend): } payment_statuses = {"SUCCESS": True, "PENDING": None, "FAILURE": False} - supported_units = set([Unit.sat, Unit.msat]) + supported_units = {Unit.sat, Unit.msat} supports_description: bool = True unit = Unit.sat diff --git a/cashu/lightning/clnrest.py b/cashu/lightning/clnrest.py index 0dcb68eb..4a337687 100644 --- a/cashu/lightning/clnrest.py +++ b/cashu/lightning/clnrest.py @@ -27,7 +27,7 @@ class CLNRestWallet(LightningBackend): - supported_units = set([Unit.sat, Unit.msat]) + supported_units = {Unit.sat, Unit.msat} unit = Unit.sat supports_mpp = settings.mint_clnrest_enable_mpp supports_incoming_payment_stream: bool = True @@ -41,7 +41,7 @@ def __init__(self, unit: Unit = Unit.sat, **kwargs): raise Exception("missing rune for clnrest") # load from file or use as is if os.path.exists(rune_settings): - with open(rune_settings, "r") as f: + with open(rune_settings) as f: rune = f.read() rune = rune.strip() else: diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index 51a1ac23..58b462b3 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -27,7 +27,7 @@ class CoreLightningRestWallet(LightningBackend): - supported_units = set([Unit.sat, Unit.msat]) + supported_units = {Unit.sat, Unit.msat} unit = Unit.sat supports_incoming_payment_stream: bool = True supports_description: bool = True diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index b9d3cb39..18a3e6ff 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -39,12 +39,12 @@ class FakeWallet(LightningBackend): privkey: str = hashlib.pbkdf2_hmac( "sha256", secret.encode(), - ("FakeWallet").encode(), + b"FakeWallet", 2048, 32, ).hex() - supported_units = set([Unit.sat, Unit.msat, Unit.usd, Unit.eur]) + supported_units = {Unit.sat, Unit.msat, Unit.usd, Unit.eur} unit = Unit.sat supports_incoming_payment_stream: bool = True diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index 7daff1fe..14aa6ee6 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -25,7 +25,7 @@ class LNbitsWallet(LightningBackend): """https://github.com/lnbits/lnbits""" - supported_units = set([Unit.sat]) + supported_units = {Unit.sat} unit = Unit.sat supports_incoming_payment_stream: bool = True supports_description: bool = True diff --git a/cashu/lightning/lnd_grpc/lnd_grpc.py b/cashu/lightning/lnd_grpc/lnd_grpc.py index fa667ee0..7f7cecc9 100644 --- a/cashu/lightning/lnd_grpc/lnd_grpc.py +++ b/cashu/lightning/lnd_grpc/lnd_grpc.py @@ -49,7 +49,7 @@ class LndRPCWallet(LightningBackend): supports_mpp = settings.mint_lnd_enable_mpp supports_incoming_payment_stream = True - supported_units = set([Unit.sat, Unit.msat]) + supported_units = {Unit.sat, Unit.msat} supports_description: bool = True unit = Unit.sat diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index 18dbcd49..6bca1501 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -32,7 +32,7 @@ class LndRestWallet(LightningBackend): supports_mpp = settings.mint_lnd_enable_mpp supports_incoming_payment_stream = True - supported_units = set([Unit.sat, Unit.msat]) + supported_units = {Unit.sat, Unit.msat} supports_description: bool = True unit = Unit.sat diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index 983b1935..2f1f0cb7 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -274,7 +274,7 @@ def _verify_output_p2pk_spending_conditions( # all pubkeys and n_sigs must be the same assert ( - len(set([tuple(pubs_output) for pubs_output in pubkeys_per_proof])) == 1 + len({tuple(pubs_output) for pubs_output in pubkeys_per_proof}) == 1 ), "pubkeys in all proofs must match." assert len(set(n_sigs_per_proof)) == 1, "n_sigs in all proofs must match." diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 429d2ee3..5521d64f 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -234,7 +234,7 @@ def _verify_units_match( return units_proofs[0] def get_fees_for_proofs(self, proofs: List[Proof]) -> int: - if not len(set([self.keysets[p.id].unit for p in proofs])) == 1: + if not len({self.keysets[p.id].unit for p in proofs}) == 1: raise TransactionUnitError("inputs have different units.") fee = (sum([self.keysets[p.id].input_fee_ppk for p in proofs]) + 999) // 1000 return fee diff --git a/cashu/nostr/client/cbc.py b/cashu/nostr/client/cbc.py index e69e8b5b..d0a92fbc 100644 --- a/cashu/nostr/client/cbc.py +++ b/cashu/nostr/client/cbc.py @@ -11,7 +11,7 @@ BLOCK_SIZE = 16 -class AESCipher(object): +class AESCipher: """This class is compatible with crypto.createCipheriv('aes-256-cbc')""" def __init__(self, key=None): diff --git a/cashu/nostr/filter.py b/cashu/nostr/filter.py index f119079c..78049756 100644 --- a/cashu/nostr/filter.py +++ b/cashu/nostr/filter.py @@ -76,7 +76,7 @@ def matches(self, event: Event) -> bool: return False if self.tags: - e_tag_identifiers = set([e_tag[0] for e_tag in event.tags]) + e_tag_identifiers = {e_tag[0] for e_tag in event.tags} for f_tag, f_tag_values in self.tags.items(): # Omit any NIP-01 or NIP-12 "#" chars on single-letter tags f_tag = f_tag.replace("#", "") diff --git a/cashu/tor/tor.py b/cashu/tor/tor.py index 114441c1..b4f9193d 100755 --- a/cashu/tor/tor.py +++ b/cashu/tor/tor.py @@ -144,7 +144,7 @@ def is_port_open(self): def read_pid(self): if not os.path.isfile(self.pid_file): return None - with open(self.pid_file, "r") as f: + with open(self.pid_file) as f: pid = f.readlines() # check if pid is valid if len(pid) == 0 or not int(pid[0]) > 0: diff --git a/cashu/wallet/proofs.py b/cashu/wallet/proofs.py index c0169e18..6172097f 100644 --- a/cashu/wallet/proofs.py +++ b/cashu/wallet/proofs.py @@ -34,7 +34,7 @@ async def _get_proofs_per_minturl( self, proofs: List[Proof], unit: Optional[Unit] = None ) -> Dict[str, List[Proof]]: ret: Dict[str, List[Proof]] = {} - keyset_ids = set([p.id for p in proofs]) + keyset_ids = {p.id for p in proofs} for id in keyset_ids: if id is None: continue @@ -178,7 +178,7 @@ async def _make_tokenv3( if not keysets: raise ValueError("No keysets found for proofs") assert ( - len(set([k.unit for k in keysets.values()])) == 1 + len({k.unit for k in keysets.values()}) == 1 ), "All keysets must have the same unit" unit = keysets[list(keysets.keys())[0]].unit @@ -216,14 +216,14 @@ async def _make_tokenv4( except KeyError: raise ValueError("Keysets of proofs are not loaded in wallet") # we make sure that all proofs are from keysets of the same mint - if len(set([k.mint_url for k in keysets])) > 1: + if len({k.mint_url for k in keysets}) > 1: raise ValueError("TokenV4 can only contain proofs from a single mint URL") mint_url = keysets[0].mint_url if not mint_url: raise ValueError("No mint URL found for keyset") # we make sure that all keysets have the same unit - if len(set([k.unit for k in keysets])) > 1: + if len({k.unit for k in keysets}) > 1: raise ValueError( "TokenV4 can only contain proofs from keysets with the same unit" ) diff --git a/cashu/wallet/v1_api.py b/cashu/wallet/v1_api.py index f1a8bb16..5905391a 100644 --- a/cashu/wallet/v1_api.py +++ b/cashu/wallet/v1_api.py @@ -99,7 +99,7 @@ async def wrapper(self, *args, **kwargs): return wrapper -class LedgerAPI(LedgerAPIDeprecated, object): +class LedgerAPI(LedgerAPIDeprecated): tor: TorProxy db: Database # we need the db for melt_deprecated httpx: httpx.AsyncClient From d8d3037cc538b9c7a713c0fa25c72e37915518ba Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:55:35 +0200 Subject: [PATCH 3/3] WIP: New melt flow (#622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `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 --- cashu/core/base.py | 68 ++++-- cashu/core/models.py | 4 +- cashu/core/settings.py | 3 +- cashu/lightning/base.py | 64 +++++- cashu/lightning/blink.py | 137 +++++++----- cashu/lightning/clnrest.py | 111 +++++----- cashu/lightning/corelightningrest.py | 115 +++++------ cashu/lightning/fake.py | 30 ++- cashu/lightning/lnbits.py | 66 ++++-- cashu/lightning/lnd_grpc/lnd_grpc.py | 85 ++++---- cashu/lightning/lndrest.py | 108 +++++----- cashu/lightning/strike.py | 186 +++++++++++------ cashu/mint/crud.py | 77 ++++--- cashu/mint/db/write.py | 10 +- cashu/mint/ledger.py | 223 +++++++++++--------- cashu/mint/migrations.py | 19 +- cashu/mint/router.py | 4 +- cashu/mint/router_deprecated.py | 8 +- cashu/mint/tasks.py | 3 +- cashu/wallet/cli/cli.py | 24 ++- cashu/wallet/lightning/lightning.py | 47 +++-- cashu/wallet/wallet.py | 15 +- mypy.ini | 2 +- tests/conftest.py | 2 +- tests/helpers.py | 14 +- tests/test_db.py | 5 +- tests/test_mint_api.py | 4 +- tests/test_mint_db.py | 30 ++- tests/test_mint_init.py | 124 +++++++++-- tests/test_mint_lightning_blink.py | 10 +- tests/test_mint_melt.py | 297 +++++++++++++++++++++++++++ tests/test_mint_operations.py | 30 ++- tests/test_mint_regtest.py | 249 +++++++++++++++++++++- tests/test_wallet_api.py | 10 +- tests/test_wallet_lightning.py | 16 +- tests/test_wallet_p2pk.py | 4 +- tests/test_wallet_regtest.py | 9 +- tests/test_wallet_regtest_mpp.py | 13 +- tests/test_wallet_subscription.py | 11 +- 39 files changed, 1565 insertions(+), 672 deletions(-) create mode 100644 tests/test_mint_melt.py diff --git a/cashu/core/base.py b/cashu/core/base.py index 7f9ccee4..537cc32f 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -80,6 +80,18 @@ def identifier(self) -> str: def kind(self) -> JSONRPCSubscriptionKinds: return JSONRPCSubscriptionKinds.PROOF_STATE + @property + def unspent(self) -> bool: + return self.state == ProofSpentState.unspent + + @property + def spent(self) -> bool: + return self.state == ProofSpentState.spent + + @property + def pending(self) -> bool: + return self.state == ProofSpentState.pending + class HTLCWitness(BaseModel): preimage: Optional[str] = None @@ -290,7 +302,6 @@ class MeltQuote(LedgerEvent): unit: str amount: int fee_reserve: int - paid: bool state: MeltQuoteState created_time: Union[int, None] = None paid_time: Union[int, None] = None @@ -325,7 +336,6 @@ def from_row(cls, row: Row): unit=row["unit"], amount=row["amount"], fee_reserve=row["fee_reserve"], - paid=row["paid"], state=MeltQuoteState[row["state"]], created_time=created_time, paid_time=paid_time, @@ -344,17 +354,34 @@ def identifier(self) -> str: def kind(self) -> JSONRPCSubscriptionKinds: return JSONRPCSubscriptionKinds.BOLT11_MELT_QUOTE + @property + def unpaid(self) -> bool: + return self.state == MeltQuoteState.unpaid + + @property + def pending(self) -> bool: + return self.state == MeltQuoteState.pending + + @property + def paid(self) -> bool: + return self.state == MeltQuoteState.paid + # method that is invoked when the `state` attribute is changed. to protect the state from being set to anything else if the current state is paid def __setattr__(self, name, value): # an unpaid quote can only be set to pending or paid - if name == "state" and self.state == MeltQuoteState.unpaid: + if name == "state" and self.unpaid: if value not in [MeltQuoteState.pending, MeltQuoteState.paid]: raise Exception( f"Cannot change state of an unpaid melt quote to {value}." ) # a paid quote can not be changed - if name == "state" and self.state == MeltQuoteState.paid: + if name == "state" and self.paid: raise Exception("Cannot change state of a paid melt quote.") + + if name == "paid": + raise Exception( + "MeltQuote does not support `paid` anymore! Use `state` instead." + ) super().__setattr__(name, value) @@ -375,8 +402,6 @@ class MintQuote(LedgerEvent): checking_id: str unit: str amount: int - paid: bool - issued: bool state: MintQuoteState created_time: Union[int, None] = None paid_time: Union[int, None] = None @@ -401,8 +426,6 @@ def from_row(cls, row: Row): checking_id=row["checking_id"], unit=row["unit"], amount=row["amount"], - paid=row["paid"], - issued=row["issued"], state=MintQuoteState[row["state"]], created_time=created_time, paid_time=paid_time, @@ -417,24 +440,45 @@ def identifier(self) -> str: def kind(self) -> JSONRPCSubscriptionKinds: return JSONRPCSubscriptionKinds.BOLT11_MINT_QUOTE + @property + def unpaid(self) -> bool: + return self.state == MintQuoteState.unpaid + + @property + def paid(self) -> bool: + return self.state == MintQuoteState.paid + + @property + def pending(self) -> bool: + return self.state == MintQuoteState.pending + + @property + def issued(self) -> bool: + return self.state == MintQuoteState.issued + def __setattr__(self, name, value): # un unpaid quote can only be set to paid - if name == "state" and self.state == MintQuoteState.unpaid: + if name == "state" and self.unpaid: if value != MintQuoteState.paid: raise Exception( f"Cannot change state of an unpaid mint quote to {value}." ) # a paid quote can only be set to pending or issued - if name == "state" and self.state == MintQuoteState.paid: + if name == "state" and self.paid: if value != MintQuoteState.pending and value != MintQuoteState.issued: raise Exception(f"Cannot change state of a paid mint quote to {value}.") # a pending quote can only be set to paid or issued - if name == "state" and self.state == MintQuoteState.pending: + if name == "state" and self.pending: if value not in [MintQuoteState.paid, MintQuoteState.issued]: raise Exception("Cannot change state of a pending mint quote.") # an issued quote cannot be changed - if name == "state" and self.state == MintQuoteState.issued: + if name == "state" and self.issued: raise Exception("Cannot change state of an issued mint quote.") + + if name == "paid": + raise Exception( + "MintQuote does not support `paid` anymore! Use `state` instead." + ) super().__setattr__(name, value) diff --git a/cashu/core/models.py b/cashu/core/models.py index f16ea71a..0c6b65bd 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -213,7 +213,7 @@ class PostMeltQuoteResponse(BaseModel): fee_reserve: int # input fee reserve paid: Optional[ bool - ] # whether the request has been paid # DEPRECATED as per NUT PR #136 + ] = None # whether the request has been paid # DEPRECATED as per NUT PR #136 state: Optional[str] # state of the quote expiry: Optional[int] # expiry of the quote payment_preimage: Optional[str] = None # payment preimage @@ -224,6 +224,8 @@ def from_melt_quote(self, melt_quote: MeltQuote) -> "PostMeltQuoteResponse": to_dict = melt_quote.dict() # turn state into string to_dict["state"] = melt_quote.state.value + # add deprecated "paid" field + to_dict["paid"] = melt_quote.paid return PostMeltQuoteResponse.parse_obj(to_dict) diff --git a/cashu/core/settings.py b/cashu/core/settings.py index e7cf5d0c..83e59aab 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -135,7 +135,8 @@ class FakeWalletSettings(MintSettings): fakewallet_delay_outgoing_payment: Optional[float] = Field(default=3.0) fakewallet_delay_incoming_payment: Optional[float] = Field(default=3.0) fakewallet_stochastic_invoice: bool = Field(default=False) - fakewallet_payment_state: Optional[bool] = Field(default=None) + fakewallet_payment_state: Optional[str] = Field(default="SETTLED") + fakewallet_pay_invoice_state: Optional[str] = Field(default="SETTLED") class MintInformation(CashuSettings): diff --git a/cashu/lightning/base.py b/cashu/lightning/base.py index c1500baa..c4169946 100644 --- a/cashu/lightning/base.py +++ b/cashu/lightning/base.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from enum import Enum, auto from typing import AsyncGenerator, Coroutine, Optional, Union from pydantic import BaseModel @@ -12,8 +13,8 @@ class StatusResponse(BaseModel): - error_message: Optional[str] balance: Union[int, float] + error_message: Optional[str] = None class InvoiceQuoteResponse(BaseModel): @@ -34,36 +35,77 @@ class InvoiceResponse(BaseModel): error_message: Optional[str] = None +class PaymentResult(Enum): + SETTLED = auto() + FAILED = auto() + PENDING = auto() + UNKNOWN = auto() + + def __str__(self): + return self.name + + class PaymentResponse(BaseModel): - ok: Optional[bool] = None # True: paid, False: failed, None: pending or unknown + result: PaymentResult checking_id: Optional[str] = None fee: Optional[Amount] = None preimage: Optional[str] = None error_message: Optional[str] = None + @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 + class PaymentStatus(BaseModel): - paid: Optional[bool] = None + result: PaymentResult fee: Optional[Amount] = None preimage: Optional[str] = None + error_message: Optional[str] = None @property def pending(self) -> bool: - return self.paid is not True + return self.result == PaymentResult.PENDING + + @property + def settled(self) -> bool: + return self.result == PaymentResult.SETTLED @property def failed(self) -> bool: - return self.paid is False + return self.result == PaymentResult.FAILED + + @property + def unknown(self) -> bool: + return self.result == PaymentResult.UNKNOWN def __str__(self) -> str: - if self.paid is True: - return "settled" - elif self.paid is False: + if self.result == PaymentResult.SETTLED: + return ( + "settled" + + (f" (preimage: {self.preimage})" if self.preimage else "") + + (f" (fee: {self.fee})" if self.fee else "") + ) + elif self.result == PaymentResult.FAILED: return "failed" - elif self.paid is None: + elif self.result == PaymentResult.PENDING: return "still pending" - else: - return "unknown (should never happen)" + else: # self.result == PaymentResult.UNKNOWN: + return "unknown" + ( + f" (Error: {self.error_message})" if self.error_message else "" + ) class LightningBackend(ABC): diff --git a/cashu/lightning/blink.py b/cashu/lightning/blink.py index 3f05130a..5c9fe7ba 100644 --- a/cashu/lightning/blink.py +++ b/cashu/lightning/blink.py @@ -1,4 +1,3 @@ -# type: ignore import json import math from typing import AsyncGenerator, Dict, Optional, Union @@ -18,6 +17,7 @@ LightningBackend, PaymentQuoteResponse, PaymentResponse, + PaymentResult, PaymentStatus, StatusResponse, ) @@ -30,6 +30,22 @@ PROBE_FEE_TIMEOUT_SEC = 1 MINIMUM_FEE_MSAT = 2000 +INVOICE_RESULT_MAP = { + "PENDING": PaymentResult.PENDING, + "PAID": PaymentResult.SETTLED, + "EXPIRED": PaymentResult.FAILED, +} +PAYMENT_EXECUTION_RESULT_MAP = { + "SUCCESS": PaymentResult.SETTLED, + "ALREADY_PAID": PaymentResult.FAILED, + "FAILURE": PaymentResult.FAILED, +} +PAYMENT_RESULT_MAP = { + "SUCCESS": PaymentResult.SETTLED, + "PENDING": PaymentResult.PENDING, + "FAILURE": PaymentResult.FAILED, +} + class BlinkWallet(LightningBackend): """https://dev.blink.sv/ @@ -38,13 +54,6 @@ class BlinkWallet(LightningBackend): wallet_ids: Dict[Unit, str] = {} endpoint = "https://api.blink.sv/graphql" - invoice_statuses = {"PENDING": None, "PAID": True, "EXPIRED": False} - payment_execution_statuses = { - "SUCCESS": True, - "ALREADY_PAID": None, - "FAILURE": False, - } - payment_statuses = {"SUCCESS": True, "PENDING": None, "FAILURE": False} supported_units = {Unit.sat, Unit.msat} supports_description: bool = True @@ -66,12 +75,13 @@ def __init__(self, unit: Unit = Unit.sat, **kwargs): async def status(self) -> StatusResponse: try: + data = { + "query": "query me { me { defaultAccount { wallets { id walletCurrency balance }}}}", + "variables": {}, + } r = await self.client.post( url=self.endpoint, - data=( - '{"query":"query me { me { defaultAccount { wallets { id' - ' walletCurrency balance }}}}", "variables":{}}' - ), + data=json.dumps(data), # type: ignore ) r.raise_for_status() except Exception as exc: @@ -96,10 +106,10 @@ async def status(self) -> StatusResponse: resp.get("data", {}).get("me", {}).get("defaultAccount", {}).get("wallets") ): if wallet_dict.get("walletCurrency") == "USD": - self.wallet_ids[Unit.usd] = wallet_dict["id"] + self.wallet_ids[Unit.usd] = wallet_dict["id"] # type: ignore elif wallet_dict.get("walletCurrency") == "BTC": - self.wallet_ids[Unit.sat] = wallet_dict["id"] - balance = wallet_dict["balance"] + self.wallet_ids[Unit.sat] = wallet_dict["id"] # type: ignore + balance = wallet_dict["balance"] # type: ignore return StatusResponse(error_message=None, balance=balance) @@ -144,7 +154,7 @@ async def create_invoice( try: r = await self.client.post( url=self.endpoint, - data=json.dumps(data), + data=json.dumps(data), # type: ignore ) r.raise_for_status() except Exception as e: @@ -197,13 +207,16 @@ async def pay_invoice( try: r = await self.client.post( url=self.endpoint, - data=json.dumps(data), + data=json.dumps(data), # type: ignore timeout=None, ) r.raise_for_status() except Exception as e: logger.error(f"Blink API error: {str(e)}") - return PaymentResponse(ok=False, error_message=str(e)) + return PaymentResponse( + result=PaymentResult.UNKNOWN, + error_message=str(e), + ) resp: dict = r.json() @@ -211,15 +224,22 @@ async def pay_invoice( fee: Union[None, int] = None if resp.get("data", {}).get("lnInvoicePaymentSend", {}).get("errors"): error_message = ( - resp["data"]["lnInvoicePaymentSend"]["errors"][0].get("message") + resp["data"]["lnInvoicePaymentSend"]["errors"][0].get("message") # type: ignore or "Unknown error" ) - paid = self.payment_execution_statuses[ - resp.get("data", {}).get("lnInvoicePaymentSend", {}).get("status") - ] - if paid is None: - error_message = "Invoice already paid." + status_str = resp.get("data", {}).get("lnInvoicePaymentSend", {}).get("status") + result = PAYMENT_EXECUTION_RESULT_MAP[status_str] + + if status_str == "ALREADY_PAID": + error_message = "Invoice already paid" + + if result == PaymentResult.FAILED: + return PaymentResponse( + result=result, + error_message=error_message, + checking_id=quote.request, + ) if resp.get("data", {}).get("lnInvoicePaymentSend", {}).get("transaction", {}): fee = ( @@ -230,15 +250,14 @@ async def pay_invoice( ) checking_id = quote.request - # we check the payment status to get the preimage preimage: Union[None, str] = None payment_status = await self.get_payment_status(checking_id) - if payment_status.paid: + if payment_status.settled: preimage = payment_status.preimage return PaymentResponse( - ok=paid, + result=result, checking_id=checking_id, fee=Amount(Unit.sat, fee) if fee else None, preimage=preimage, @@ -261,22 +280,27 @@ async def get_invoice_status(self, checking_id: str) -> PaymentStatus: "variables": variables, } try: - r = await self.client.post(url=self.endpoint, data=json.dumps(data)) + r = await self.client.post(url=self.endpoint, data=json.dumps(data)) # type: ignore r.raise_for_status() except Exception as e: logger.error(f"Blink API error: {str(e)}") - return PaymentStatus(paid=None) + return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e)) resp: dict = r.json() - if resp.get("data", {}).get("lnInvoicePaymentStatus", {}).get("errors"): + error_message = ( + resp.get("data", {}).get("lnInvoicePaymentStatus", {}).get("errors") + ) + if error_message: logger.error( "Blink Error", - resp.get("data", {}).get("lnInvoicePaymentStatus", {}).get("errors"), + error_message, + ) + return PaymentStatus( + result=PaymentResult.UNKNOWN, error_message=error_message ) - return PaymentStatus(paid=None) - paid = self.invoice_statuses[ + result = INVOICE_RESULT_MAP[ resp.get("data", {}).get("lnInvoicePaymentStatus", {}).get("status") ] - return PaymentStatus(paid=paid) + return PaymentStatus(result=result) async def get_payment_status(self, checking_id: str) -> PaymentStatus: # Checking ID is the payment request and blink wants the payment hash @@ -311,16 +335,11 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: """, "variables": variables, } - - try: - r = await self.client.post( - url=self.endpoint, - data=json.dumps(data), - ) - r.raise_for_status() - except Exception as e: - logger.error(f"Blink API error: {str(e)}") - return PaymentResponse(ok=False, error_message=str(e)) + r = await self.client.post( + url=self.endpoint, + data=json.dumps(data), # type: ignore + ) + r.raise_for_status() resp: dict = r.json() @@ -332,7 +351,9 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: .get("walletById", {}) .get("transactionsByPaymentHash") ): - return PaymentStatus(paid=None) + return PaymentStatus( + result=PaymentResult.UNKNOWN, error_message="No payment found" + ) all_payments_with_this_hash = ( resp.get("data", {}) @@ -345,12 +366,14 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: # Blink API edge case: for a previously failed payment attempt, it returns the two payments with the same hash # if there are two payments with the same hash with "direction" == "SEND" and "RECEIVE" # it means that the payment previously failed and we can ignore the attempt and return - # PaymentStatus(paid=None) + # PaymentStatus(status=FAILED) if len(all_payments_with_this_hash) == 2 and all( - p["direction"] in [DIRECTION_SEND, DIRECTION_RECEIVE] + p["direction"] in [DIRECTION_SEND, DIRECTION_RECEIVE] # type: ignore for p in all_payments_with_this_hash ): - return PaymentStatus(paid=None) + return PaymentStatus( + result=PaymentResult.FAILED, error_message="Payment failed" + ) # if there is only one payment with the same hash, it means that the payment might have succeeded # we only care about the payment with "direction" == "SEND" @@ -363,15 +386,17 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: None, ) if not payment: - return PaymentStatus(paid=None) + return PaymentStatus( + result=PaymentResult.UNKNOWN, error_message="No payment found" + ) # we read the status of the payment - paid = self.payment_statuses[payment["status"]] - fee = payment["settlementFee"] - preimage = payment["settlementVia"].get("preImage") + result = PAYMENT_RESULT_MAP[payment["status"]] # type: ignore + fee = payment["settlementFee"] # type: ignore + preimage = payment["settlementVia"].get("preImage") # type: ignore return PaymentStatus( - paid=paid, + result=result, fee=Amount(Unit.sat, fee), preimage=preimage, ) @@ -404,7 +429,7 @@ async def get_payment_quote( try: r = await self.client.post( url=self.endpoint, - data=json.dumps(data), + data=json.dumps(data), # type: ignore timeout=PROBE_FEE_TIMEOUT_SEC, ) r.raise_for_status() @@ -413,7 +438,7 @@ async def get_payment_quote( # if there was an error, we simply ignore the response and decide the fees ourselves fees_response_msat = 0 logger.debug( - f"Blink probe error: {resp['data']['lnInvoiceFeeProbe']['errors'][0].get('message')}" + f"Blink probe error: {resp['data']['lnInvoiceFeeProbe']['errors'][0].get('message')}" # type: ignore ) else: @@ -454,5 +479,5 @@ async def get_payment_quote( 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") diff --git a/cashu/lightning/clnrest.py b/cashu/lightning/clnrest.py index 4a337687..23832f83 100644 --- a/cashu/lightning/clnrest.py +++ b/cashu/lightning/clnrest.py @@ -20,11 +20,26 @@ LightningBackend, PaymentQuoteResponse, PaymentResponse, + PaymentResult, PaymentStatus, StatusResponse, Unsupported, ) +# https://docs.corelightning.org/reference/lightning-pay +PAYMENT_RESULT_MAP = { + "complete": PaymentResult.SETTLED, + "pending": PaymentResult.PENDING, + "failed": PaymentResult.FAILED, +} + +# https://docs.corelightning.org/reference/lightning-listinvoices +INVOICE_RESULT_MAP = { + "paid": PaymentResult.SETTLED, + "unpaid": PaymentResult.PENDING, + "expired": PaymentResult.FAILED, +} + class CLNRestWallet(LightningBackend): supported_units = {Unit.sat, Unit.msat} @@ -68,12 +83,6 @@ def __init__(self, unit: Unit = Unit.sat, **kwargs): base_url=self.url, verify=self.cert, headers=self.auth ) self.last_pay_index = 0 - self.statuses = { - "paid": True, - "complete": True, - "failed": False, - "pending": None, - } async def cleanup(self): try: @@ -101,7 +110,7 @@ async def status(self) -> StatusResponse: if len(data) == 0: return StatusResponse(error_message="no data", balance=0) balance_msat = int(sum([c["our_amount_msat"] for c in data["channels"]])) - return StatusResponse(error_message=None, balance=balance_msat) + return StatusResponse(balance=balance_msat) async def create_invoice( self, @@ -147,8 +156,6 @@ async def create_invoice( return InvoiceResponse( ok=False, - checking_id=None, - payment_request=None, error_message=error_message, ) @@ -159,7 +166,6 @@ async def create_invoice( ok=True, checking_id=data["payment_hash"], payment_request=data["bolt11"], - error_message=None, ) async def pay_invoice( @@ -169,20 +175,14 @@ async def pay_invoice( invoice = decode(quote.request) except Bolt11Exception as exc: return PaymentResponse( - ok=False, - checking_id=None, - fee=None, - preimage=None, + result=PaymentResult.FAILED, error_message=str(exc), ) if not invoice.amount_msat or invoice.amount_msat <= 0: error_message = "0 amount invoices are not allowed" return PaymentResponse( - ok=False, - checking_id=None, - fee=None, - preimage=None, + result=PaymentResult.FAILED, error_message=error_message, ) @@ -205,11 +205,7 @@ async def pay_invoice( error_message = "mint does not support MPP" logger.error(error_message) return PaymentResponse( - ok=False, - checking_id=None, - fee=None, - preimage=None, - error_message=error_message, + result=PaymentResult.FAILED, error_message=error_message ) r = await self.client.post("/v1/pay", data=post_data, timeout=None) @@ -220,34 +216,20 @@ async def pay_invoice( except Exception: error_message = r.text return PaymentResponse( - ok=False, - checking_id=None, - fee=None, - preimage=None, - error_message=error_message, + result=PaymentResult.FAILED, error_message=error_message ) data = r.json() - if data["status"] != "complete": - return PaymentResponse( - ok=False, - checking_id=None, - fee=None, - preimage=None, - error_message="payment failed", - ) - checking_id = data["payment_hash"] preimage = data["payment_preimage"] fee_msat = data["amount_sent_msat"] - data["amount_msat"] return PaymentResponse( - ok=self.statuses.get(data["status"]), + result=PAYMENT_RESULT_MAP[data["status"]], checking_id=checking_id, fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, preimage=preimage, - error_message=None, ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: @@ -261,45 +243,44 @@ async def get_invoice_status(self, checking_id: str) -> PaymentStatus: if r.is_error or "message" in data or data.get("invoices") is None: raise Exception("error in cln response") - return PaymentStatus(paid=self.statuses.get(data["invoices"][0]["status"])) + return PaymentStatus( + result=INVOICE_RESULT_MAP[data["invoices"][0]["status"]], + ) except Exception as e: logger.error(f"Error getting invoice status: {e}") - return PaymentStatus(paid=None) + return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e)) async def get_payment_status(self, checking_id: str) -> PaymentStatus: r = await self.client.post( "/v1/listpays", data={"payment_hash": checking_id}, ) - try: - r.raise_for_status() - data = r.json() + r.raise_for_status() + data = r.json() - if not data.get("pays"): - # payment not found - logger.error(f"payment not found: {data.get('pays')}") - raise Exception("payment not found") + if not data.get("pays"): + # payment not found + logger.error(f"payment not found: {data.get('pays')}") + return PaymentStatus( + result=PaymentResult.UNKNOWN, error_message="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}") + 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] + 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"] + 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( - paid=self.statuses.get(pay["status"]), - 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}") - return PaymentStatus(paid=None) + return PaymentStatus( + result=PAYMENT_RESULT_MAP[pay["status"]], + fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, + preimage=preimage, + ) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: # call listinvoices to determine the last pay_index diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index 58b462b3..f2a92740 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -19,12 +19,27 @@ LightningBackend, PaymentQuoteResponse, PaymentResponse, + PaymentResult, PaymentStatus, StatusResponse, Unsupported, ) from .macaroon import load_macaroon +# https://docs.corelightning.org/reference/lightning-pay +PAYMENT_RESULT_MAP = { + "complete": PaymentResult.SETTLED, + "pending": PaymentResult.PENDING, + "failed": PaymentResult.FAILED, +} + +# https://docs.corelightning.org/reference/lightning-listinvoices +INVOICE_RESULT_MAP = { + "paid": PaymentResult.SETTLED, + "unpaid": PaymentResult.PENDING, + "expired": PaymentResult.FAILED, +} + class CoreLightningRestWallet(LightningBackend): supported_units = {Unit.sat, Unit.msat} @@ -61,12 +76,6 @@ def __init__(self, unit: Unit = Unit.sat, **kwargs): base_url=self.url, verify=self.cert, headers=self.auth ) self.last_pay_index = 0 - self.statuses = { - "paid": True, - "complete": True, - "failed": False, - "pending": None, - } async def cleanup(self): try: @@ -140,8 +149,6 @@ async def create_invoice( return InvoiceResponse( ok=False, - checking_id=None, - payment_request=None, error_message=error_message, ) @@ -152,7 +159,6 @@ async def create_invoice( ok=True, checking_id=data["payment_hash"], payment_request=data["bolt11"], - error_message=None, ) async def pay_invoice( @@ -162,20 +168,14 @@ async def pay_invoice( invoice = decode(quote.request) except Bolt11Exception as exc: return PaymentResponse( - ok=False, - checking_id=None, - fee=None, - preimage=None, + result=PaymentResult.FAILED, error_message=str(exc), ) if not invoice.amount_msat or invoice.amount_msat <= 0: error_message = "0 amount invoices are not allowed" return PaymentResponse( - ok=False, - checking_id=None, - fee=None, - preimage=None, + result=PaymentResult.FAILED, error_message=error_message, ) fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100 @@ -193,38 +193,25 @@ async def pay_invoice( if r.is_error or "error" in r.json(): try: data = r.json() - error_message = data["error"] + error_message = data["error"]["message"] except Exception: error_message = r.text return PaymentResponse( - ok=False, - checking_id=None, - fee=None, - preimage=None, + result=PaymentResult.FAILED, error_message=error_message, ) data = r.json() - if data["status"] != "complete": - return PaymentResponse( - ok=False, - checking_id=None, - fee=None, - preimage=None, - error_message="payment failed", - ) - checking_id = data["payment_hash"] preimage = data["payment_preimage"] fee_msat = data["amount_sent_msat"] - data["amount_msat"] return PaymentResponse( - ok=self.statuses.get(data["status"]), + result=PAYMENT_RESULT_MAP[data["status"]], checking_id=checking_id, fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, preimage=preimage, - error_message=None, ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: @@ -238,45 +225,44 @@ async def get_invoice_status(self, checking_id: str) -> PaymentStatus: if r.is_error or "error" in data or data.get("invoices") is None: raise Exception("error in cln response") - return PaymentStatus(paid=self.statuses.get(data["invoices"][0]["status"])) + return PaymentStatus( + result=INVOICE_RESULT_MAP[data["invoices"][0]["status"]], + ) except Exception as e: logger.error(f"Error getting invoice status: {e}") - return PaymentStatus(paid=None) + return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e)) async def get_payment_status(self, checking_id: str) -> PaymentStatus: r = await self.client.get( "/v1/pay/listPays", params={"payment_hash": checking_id}, ) - try: - r.raise_for_status() - data = r.json() + r.raise_for_status() + data = r.json() - if not data.get("pays"): - # payment not found - logger.error(f"payment not found: {data.get('pays')}") - raise Exception("payment not found") + if not data.get("pays"): + # payment not found + logger.error(f"payment not found: {data.get('pays')}") + return PaymentStatus( + result=PaymentResult.UNKNOWN, error_message="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}") + 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] + 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"] + 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( - paid=self.statuses.get(pay["status"]), - 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}") - return PaymentStatus(paid=None) + return PaymentStatus( + result=PAYMENT_RESULT_MAP[pay["status"]], + fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, + preimage=preimage, + ) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: # call listinvoices to determine the last pay_index @@ -285,7 +271,8 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: data = r.json() if r.is_error or "error" in data: raise Exception("error in cln response") - self.last_pay_index = data["invoices"][-1]["pay_index"] + if data.get("invoices"): + self.last_pay_index = data["invoices"][-1]["pay_index"] while True: try: @@ -315,9 +302,11 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: ) paid_invoce = r.json() logger.trace(f"paid invoice: {paid_invoce}") - assert self.statuses[ - paid_invoce["invoices"][0]["status"] - ], "streamed invoice not paid" + if ( + INVOICE_RESULT_MAP[paid_invoce["invoices"][0]["status"]] + != PaymentResult.SETTLED + ): + raise Exception("invoice not paid") assert "invoices" in paid_invoce, "no invoices in response" assert len(paid_invoce["invoices"]), "no invoices in response" yield paid_invoce["invoices"][0]["payment_hash"] diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index 18a3e6ff..7e8c6aca 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -23,6 +23,7 @@ LightningBackend, PaymentQuoteResponse, PaymentResponse, + PaymentResult, PaymentStatus, StatusResponse, ) @@ -151,6 +152,14 @@ async def pay_invoice(self, quote: MeltQuote, fee_limit: int) -> PaymentResponse if settings.fakewallet_delay_outgoing_payment: await asyncio.sleep(settings.fakewallet_delay_outgoing_payment) + if settings.fakewallet_pay_invoice_state: + return PaymentResponse( + result=PaymentResult[settings.fakewallet_pay_invoice_state], + checking_id=invoice.payment_hash, + fee=Amount(unit=self.unit, amount=1), + preimage=self.payment_secrets.get(invoice.payment_hash) or "0" * 64, + ) + if invoice.payment_hash in self.payment_secrets or settings.fakewallet_brr: if invoice not in self.paid_invoices_outgoing: self.paid_invoices_outgoing.append(invoice) @@ -158,28 +167,33 @@ async def pay_invoice(self, quote: MeltQuote, fee_limit: int) -> PaymentResponse raise ValueError("Invoice already paid") return PaymentResponse( - ok=True, + result=PaymentResult.SETTLED, checking_id=invoice.payment_hash, fee=Amount(unit=self.unit, amount=1), preimage=self.payment_secrets.get(invoice.payment_hash) or "0" * 64, ) else: return PaymentResponse( - ok=False, error_message="Only internal invoices can be used!" + result=PaymentResult.FAILED, + error_message="Only internal invoices can be used!", ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: await self.mark_invoice_paid(self.create_dummy_bolt11(checking_id), delay=False) paid_chceking_ids = [i.payment_hash for i in self.paid_invoices_incoming] if checking_id in paid_chceking_ids: - paid = True + return PaymentStatus(result=PaymentResult.SETTLED) else: - paid = False - - return PaymentStatus(paid=paid) + return PaymentStatus( + result=PaymentResult.UNKNOWN, error_message="Invoice not found" + ) - async def get_payment_status(self, _: str) -> PaymentStatus: - return PaymentStatus(paid=settings.fakewallet_payment_state) + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + if settings.fakewallet_payment_state: + return PaymentStatus( + result=PaymentResult[settings.fakewallet_payment_state] + ) + return PaymentStatus(result=PaymentResult.SETTLED) async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index 14aa6ee6..ed22bfbd 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -17,6 +17,7 @@ LightningBackend, PaymentQuoteResponse, PaymentResponse, + PaymentResult, PaymentStatus, StatusResponse, ) @@ -112,11 +113,18 @@ async def pay_invoice( ) r.raise_for_status() except Exception: - return PaymentResponse(error_message=r.json()["detail"]) + return PaymentResponse( + result=PaymentResult.FAILED, error_message=r.json()["detail"] + ) if r.status_code > 299: - return PaymentResponse(error_message=(f"HTTP status: {r.reason_phrase}",)) + return PaymentResponse( + result=PaymentResult.FAILED, + error_message=(f"HTTP status: {r.reason_phrase}",), + ) if "detail" in r.json(): - return PaymentResponse(error_message=(r.json()["detail"],)) + return PaymentResponse( + result=PaymentResult.FAILED, error_message=(r.json()["detail"],) + ) data: dict = r.json() checking_id = data["payment_hash"] @@ -125,7 +133,7 @@ async def pay_invoice( payment: PaymentStatus = await self.get_payment_status(checking_id) return PaymentResponse( - ok=True, + result=payment.result, checking_id=checking_id, fee=payment.fee, preimage=payment.preimage, @@ -137,12 +145,28 @@ async def get_invoice_status(self, checking_id: str) -> PaymentStatus: url=f"{self.endpoint}/api/v1/payments/{checking_id}" ) r.raise_for_status() - except Exception: - return PaymentStatus(paid=None) + except Exception as e: + return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e)) data: dict = r.json() if data.get("detail"): - return PaymentStatus(paid=None) - return PaymentStatus(paid=r.json()["paid"]) + return PaymentStatus( + result=PaymentResult.UNKNOWN, error_message=data["detail"] + ) + + if data["paid"]: + result = PaymentResult.SETTLED + elif not data["paid"] and data["details"]["pending"]: + result = PaymentResult.PENDING + elif not data["paid"] and not data["details"]["pending"]: + result = PaymentResult.FAILED + else: + result = PaymentResult.UNKNOWN + + return PaymentStatus( + result=result, + fee=Amount(unit=Unit.msat, amount=abs(data["details"]["fee"])), + preimage=data["preimage"], + ) async def get_payment_status(self, checking_id: str) -> PaymentStatus: try: @@ -150,26 +174,32 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: url=f"{self.endpoint}/api/v1/payments/{checking_id}" ) r.raise_for_status() - except Exception: - return PaymentStatus(paid=None) + except httpx.HTTPStatusError as e: + if e.response.status_code != 404: + raise e + return PaymentStatus( + result=PaymentResult.UNKNOWN, error_message=e.response.text + ) + data = r.json() if "paid" not in data and "details" not in data: - return PaymentStatus(paid=None) + return PaymentStatus( + result=PaymentResult.UNKNOWN, error_message="invalid response" + ) - paid_value = None if data["paid"]: - paid_value = True + result = PaymentResult.SETTLED elif not data["paid"] and data["details"]["pending"]: - paid_value = None + result = PaymentResult.PENDING elif not data["paid"] and not data["details"]["pending"]: - paid_value = False + result = PaymentResult.FAILED else: - raise ValueError(f"unexpected value for paid: {data['paid']}") + result = PaymentResult.UNKNOWN return PaymentStatus( - paid=paid_value, + result=result, fee=Amount(unit=Unit.msat, amount=abs(data["details"]["fee"])), - preimage=data["preimage"], + preimage=data.get("preimage"), ) async def get_payment_quote( diff --git a/cashu/lightning/lnd_grpc/lnd_grpc.py b/cashu/lightning/lnd_grpc/lnd_grpc.py index 7f7cecc9..d6b8cb0f 100644 --- a/cashu/lightning/lnd_grpc/lnd_grpc.py +++ b/cashu/lightning/lnd_grpc/lnd_grpc.py @@ -24,6 +24,7 @@ LightningBackend, PaymentQuoteResponse, PaymentResponse, + PaymentResult, PaymentStatus, PostMeltQuoteRequest, StatusResponse, @@ -31,18 +32,18 @@ # maps statuses to None, False, True: # https://api.lightning.community/?python=#paymentpaymentstatus -PAYMENT_STATUSES = { - lnrpc.Payment.PaymentStatus.UNKNOWN: None, - lnrpc.Payment.PaymentStatus.IN_FLIGHT: None, - lnrpc.Payment.PaymentStatus.INITIATED: None, - lnrpc.Payment.PaymentStatus.SUCCEEDED: True, - lnrpc.Payment.PaymentStatus.FAILED: False, +PAYMENT_RESULT_MAP = { + lnrpc.Payment.PaymentStatus.UNKNOWN: PaymentResult.UNKNOWN, + lnrpc.Payment.PaymentStatus.IN_FLIGHT: PaymentResult.PENDING, + lnrpc.Payment.PaymentStatus.INITIATED: PaymentResult.PENDING, + lnrpc.Payment.PaymentStatus.SUCCEEDED: PaymentResult.SETTLED, + lnrpc.Payment.PaymentStatus.FAILED: PaymentResult.FAILED, } -INVOICE_STATUSES = { - lnrpc.Invoice.InvoiceState.OPEN: None, - lnrpc.Invoice.InvoiceState.SETTLED: True, - lnrpc.Invoice.InvoiceState.CANCELED: None, - lnrpc.Invoice.InvoiceState.ACCEPTED: None, +INVOICE_RESULT_MAP = { + lnrpc.Invoice.InvoiceState.OPEN: PaymentResult.PENDING, + lnrpc.Invoice.InvoiceState.SETTLED: PaymentResult.SETTLED, + lnrpc.Invoice.InvoiceState.CANCELED: PaymentResult.FAILED, + lnrpc.Invoice.InvoiceState.ACCEPTED: PaymentResult.PENDING, } @@ -181,13 +182,13 @@ async def pay_invoice( except AioRpcError as e: error_message = f"SendPaymentSync failed: {e}" return PaymentResponse( - ok=False, + result=PaymentResult.FAILED, error_message=error_message, ) if r.payment_error: return PaymentResponse( - ok=False, + result=PaymentResult.FAILED, error_message=r.payment_error, ) @@ -195,7 +196,7 @@ async def pay_invoice( fee_msat = r.payment_route.total_fees_msat preimage = r.payment_preimage.hex() return PaymentResponse( - ok=True, + result=PaymentResult.SETTLED, checking_id=checking_id, fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, preimage=preimage, @@ -262,7 +263,7 @@ async def pay_partial_invoice( except AioRpcError as e: logger.error(f"QueryRoute or SendToRouteV2 failed: {e}") return PaymentResponse( - ok=False, + result=PaymentResult.FAILED, error_message=str(e), ) @@ -270,16 +271,23 @@ async def pay_partial_invoice( error_message = f"Sending to route failed with code {r.failure.code}" logger.error(error_message) return PaymentResponse( - ok=False, + result=PaymentResult.FAILED, error_message=error_message, ) - ok = r.status == lnrpc.HTLCAttempt.HTLCStatus.SUCCEEDED + result = PaymentResult.UNKNOWN + if r.status == lnrpc.HTLCAttempt.HTLCStatus.SUCCEEDED: + result = PaymentResult.SETTLED + elif r.status == lnrpc.HTLCAttempt.HTLCStatus.IN_FLIGHT: + result = PaymentResult.PENDING + else: + result = PaymentResult.FAILED + checking_id = invoice.payment_hash fee_msat = r.route.total_fees_msat preimage = r.preimage.hex() return PaymentResponse( - ok=ok, + result=result, checking_id=checking_id, fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, preimage=preimage, @@ -299,44 +307,47 @@ async def get_invoice_status(self, checking_id: str) -> PaymentStatus: except AioRpcError as e: error_message = f"LookupInvoice failed: {e}" logger.error(error_message) - return PaymentStatus(paid=None) + return PaymentStatus(result=PaymentResult.UNKNOWN) - return PaymentStatus(paid=INVOICE_STATUSES[r.state]) + return PaymentStatus( + result=INVOICE_RESULT_MAP[r.state], + ) async def get_payment_status(self, checking_id: str) -> PaymentStatus: """ This routine checks the payment status using routerpc.TrackPaymentV2. """ # convert checking_id from hex to bytes and some LND magic - try: - checking_id_bytes = bytes.fromhex(checking_id) - except ValueError: - logger.error(f"Couldn't convert {checking_id = } to bytes") - return PaymentStatus(paid=None) - + checking_id_bytes = bytes.fromhex(checking_id) request = routerrpc.TrackPaymentRequest(payment_hash=checking_id_bytes) - try: - async with grpc.aio.secure_channel( - self.endpoint, self.combined_creds - ) as channel: - router_stub = routerstub.RouterStub(channel) + async with grpc.aio.secure_channel( + self.endpoint, self.combined_creds + ) as channel: + router_stub = routerstub.RouterStub(channel) + try: async for payment in router_stub.TrackPaymentV2(request): if payment is not None and payment.status: + preimage = ( + payment.payment_preimage + if payment.payment_preimage != "0" * 64 + else None + ) return PaymentStatus( - paid=PAYMENT_STATUSES[payment.status], + result=PAYMENT_RESULT_MAP[payment.status], fee=( Amount(unit=Unit.msat, amount=payment.fee_msat) if payment.fee_msat else None ), - preimage=payment.payment_preimage, + preimage=preimage, ) - except AioRpcError as e: - error_message = f"TrackPaymentV2 failed: {e}" - logger.error(error_message) + except AioRpcError as e: + # status = StatusCode.NOT_FOUND + if e.code() == grpc.StatusCode.NOT_FOUND: + return PaymentStatus(result=PaymentResult.UNKNOWN) - return PaymentStatus(paid=None) + return PaymentStatus(result=PaymentResult.UNKNOWN) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: while True: diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index 6bca1501..7b503397 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -21,11 +21,26 @@ LightningBackend, PaymentQuoteResponse, PaymentResponse, + PaymentResult, PaymentStatus, StatusResponse, ) from .macaroon import load_macaroon +PAYMENT_RESULT_MAP = { + "UNKNOWN": PaymentResult.UNKNOWN, + "IN_FLIGHT": PaymentResult.PENDING, + "INITIATED": PaymentResult.PENDING, + "SUCCEEDED": PaymentResult.SETTLED, + "FAILED": PaymentResult.FAILED, +} +INVOICE_RESULT_MAP = { + "OPEN": PaymentResult.PENDING, + "SETTLED": PaymentResult.SETTLED, + "CANCELED": PaymentResult.FAILED, + "ACCEPTED": PaymentResult.PENDING, +} + class LndRestWallet(LightningBackend): """https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" @@ -187,11 +202,7 @@ async def pay_invoice( if r.is_error or r.json().get("payment_error"): error_message = r.json().get("payment_error") or r.text return PaymentResponse( - ok=False, - checking_id=None, - fee=None, - preimage=None, - error_message=error_message, + result=PaymentResult.FAILED, error_message=error_message ) data = r.json() @@ -199,11 +210,10 @@ async def pay_invoice( fee_msat = int(data["payment_route"]["total_fees_msat"]) preimage = base64.b64decode(data["payment_preimage"]).hex() return PaymentResponse( - ok=True, + result=PaymentResult.SETTLED, checking_id=checking_id, fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, preimage=preimage, - error_message=None, ) async def pay_partial_invoice( @@ -237,11 +247,7 @@ async def pay_partial_invoice( if r.is_error or data.get("message"): error_message = data.get("message") or r.text return PaymentResponse( - ok=False, - checking_id=None, - fee=None, - preimage=None, - error_message=error_message, + result=PaymentResult.FAILED, error_message=error_message ) # We need to set the mpp_record for a partial payment @@ -272,58 +278,52 @@ async def pay_partial_invoice( if r.is_error or data.get("message"): error_message = data.get("message") or r.text return PaymentResponse( - ok=False, - checking_id=None, - fee=None, - preimage=None, - error_message=error_message, + result=PaymentResult.FAILED, error_message=error_message ) - ok = data.get("status") == "SUCCEEDED" + result = PAYMENT_RESULT_MAP.get(data.get("status"), PaymentResult.UNKNOWN) checking_id = invoice.payment_hash - fee_msat = int(data["route"]["total_fees_msat"]) - preimage = base64.b64decode(data["preimage"]).hex() + fee_msat = int(data["route"]["total_fees_msat"]) if data.get("route") else None + preimage = ( + base64.b64decode(data["preimage"]).hex() if data.get("preimage") else None + ) return PaymentResponse( - ok=ok, + result=result, checking_id=checking_id, fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, preimage=preimage, - error_message=None, ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: r = await self.client.get(url=f"/v1/invoice/{checking_id}") - if r.is_error or not r.json().get("settled"): - # this must also work when checking_id is not a hex recognizable by lnd - # it will return an error and no "settled" attribute on the object - return PaymentStatus(paid=None) + if r.is_error: + logger.error(f"Couldn't get invoice status: {r.text}") + return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=r.text) - return PaymentStatus(paid=True) + data = None + try: + data = r.json() + except json.JSONDecodeError as e: + logger.error(f"Incomprehensible response: {str(e)}") + return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e)) + if not data or not data.get("state"): + return PaymentStatus( + result=PaymentResult.UNKNOWN, error_message="no invoice state" + ) + return PaymentStatus( + result=INVOICE_RESULT_MAP[data["state"]], + ) async def get_payment_status(self, checking_id: str) -> PaymentStatus: """ This routine checks the payment status using routerpc.TrackPaymentV2. """ # convert checking_id from hex to base64 and some LND magic - try: - checking_id = base64.urlsafe_b64encode(bytes.fromhex(checking_id)).decode( - "ascii" - ) - except ValueError: - return PaymentStatus(paid=None) - + checking_id = base64.urlsafe_b64encode(bytes.fromhex(checking_id)).decode( + "ascii" + ) url = f"/v2/router/track/{checking_id}" - - # check payment.status: - # https://api.lightning.community/?python=#paymentpaymentstatus - statuses = { - "UNKNOWN": None, - "IN_FLIGHT": None, - "SUCCEEDED": True, - "FAILED": False, - } - async with self.client.stream("GET", url, timeout=None) as r: async for json_line in r.aiter_lines(): try: @@ -337,27 +337,37 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: else line["error"] ) logger.error(f"LND get_payment_status error: {message}") - return PaymentStatus(paid=None) + return PaymentStatus( + result=PaymentResult.UNKNOWN, error_message=message + ) payment = line.get("result") # payment exists if payment is not None and payment.get("status"): + preimage = ( + payment.get("payment_preimage") + if payment.get("payment_preimage") != "0" * 64 + else None + ) return PaymentStatus( - paid=statuses[payment["status"]], + result=PAYMENT_RESULT_MAP[payment["status"]], fee=( Amount(unit=Unit.msat, amount=payment.get("fee_msat")) if payment.get("fee_msat") else None ), - preimage=payment.get("payment_preimage"), + preimage=preimage, ) else: - return PaymentStatus(paid=None) + return PaymentStatus( + result=PaymentResult.UNKNOWN, + error_message="no payment status", + ) except Exception: continue - return PaymentStatus(paid=None) + return PaymentStatus(result=PaymentResult.UNKNOWN, error_message="timeout") async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: while True: diff --git a/cashu/lightning/strike.py b/cashu/lightning/strike.py index 2103c2ce..dbf2e07f 100644 --- a/cashu/lightning/strike.py +++ b/cashu/lightning/strike.py @@ -1,8 +1,8 @@ -# type: ignore import secrets -from typing import AsyncGenerator, Optional +from typing import AsyncGenerator, Dict, Optional, Union import httpx +from pydantic import BaseModel from ..core.base import Amount, MeltQuote, Unit from ..core.models import PostMeltQuoteRequest @@ -12,6 +12,7 @@ LightningBackend, PaymentQuoteResponse, PaymentResponse, + PaymentResult, PaymentStatus, StatusResponse, ) @@ -19,13 +20,92 @@ USDT = "USDT" +class StrikeAmount(BaseModel): + amount: str + currency: str + + +class StrikeRate(BaseModel): + amount: str + sourceCurrency: str + targetCurrency: str + + +class StrikeCreateInvoiceResponse(BaseModel): + invoiceId: str + amount: StrikeAmount + state: str + description: str + + +class StrikePaymentQuoteResponse(BaseModel): + lightningNetworkFee: StrikeAmount + paymentQuoteId: str + validUntil: str + amount: StrikeAmount + totalFee: StrikeAmount + totalAmount: StrikeAmount + + +class InvoiceQuoteResponse(BaseModel): + quoteId: str + description: str + lnInvoice: str + expiration: str + expirationInSec: int + targetAmount: StrikeAmount + sourceAmount: StrikeAmount + conversionRate: StrikeRate + + +class StrikePaymentResponse(BaseModel): + paymentId: str + state: str + result: str + completed: Optional[str] + delivered: Optional[str] + amount: StrikeAmount + totalFee: StrikeAmount + lightningNetworkFee: StrikeAmount + totalAmount: StrikeAmount + lightning: Dict[str, StrikeAmount] + + +PAYMENT_RESULT_MAP = { + "PENDING": PaymentResult.PENDING, + "COMPLETED": PaymentResult.SETTLED, + "FAILED": PaymentResult.FAILED, +} + + +INVOICE_RESULT_MAP = { + "PENDING": PaymentResult.PENDING, + "UNPAID": PaymentResult.PENDING, + "PAID": PaymentResult.SETTLED, + "CANCELLED": PaymentResult.FAILED, +} + + class StrikeWallet(LightningBackend): """https://docs.strike.me/api/""" - supported_units = [Unit.sat, Unit.usd, Unit.eur] + supported_units = set([Unit.sat, Unit.usd, Unit.eur]) supports_description: bool = False currency_map = {Unit.sat: "BTC", Unit.usd: "USD", Unit.eur: "EUR"} + def fee_int( + self, strike_quote: Union[StrikePaymentQuoteResponse, StrikePaymentResponse] + ) -> int: + fee_str = strike_quote.totalFee.amount + if strike_quote.totalFee.currency == self.currency_map[Unit.sat]: + fee = int(float(fee_str) * 1e8) + elif strike_quote.totalFee.currency in [ + self.currency_map[Unit.usd], + self.currency_map[Unit.eur], + ]: + fee = int(float(fee_str) * 100) + return fee + def __init__(self, unit: Unit, **kwargs): self.assert_unit_supported(unit) self.unit = unit @@ -98,45 +178,29 @@ async def create_invoice( payload = { "correlationId": secrets.token_hex(16), - "description": "Invoice for order 123", + "description": memo or "Invoice for order 123", "amount": {"amount": amount.to_float_string(), "currency": self.currency}, } try: r = await self.client.post(url=f"{self.endpoint}/v1/invoices", json=payload) r.raise_for_status() except Exception: - return InvoiceResponse( - paid=False, - checking_id=None, - payment_request=None, - error_message=r.json()["detail"], - ) + return InvoiceResponse(ok=False, error_message=r.json()["detail"]) - quote = r.json() - invoice_id = quote.get("invoiceId") + invoice = StrikeCreateInvoiceResponse.parse_obj(r.json()) try: payload = {"descriptionHash": secrets.token_hex(32)} r2 = await self.client.post( - f"{self.endpoint}/v1/invoices/{invoice_id}/quote", json=payload + f"{self.endpoint}/v1/invoices/{invoice.invoiceId}/quote", json=payload ) + r2.raise_for_status() except Exception: - return InvoiceResponse( - paid=False, - checking_id=None, - payment_request=None, - error_message=r.json()["detail"], - ) + return InvoiceResponse(ok=False, error_message=r.json()["detail"]) - data2 = r2.json() - payment_request = data2.get("lnInvoice") - assert payment_request, "Did not receive an invoice" - checking_id = invoice_id + quote = InvoiceQuoteResponse.parse_obj(r2.json()) return InvoiceResponse( - ok=True, - checking_id=checking_id, - payment_request=payment_request, - error_message=None, + ok=True, checking_id=invoice.invoiceId, payment_request=quote.lnInvoice ) async def get_payment_quote( @@ -153,13 +217,18 @@ async def get_payment_quote( except Exception: error_message = r.json()["data"]["message"] raise Exception(error_message) - data = r.json() + strike_quote = StrikePaymentQuoteResponse.parse_obj(r.json()) + if strike_quote.amount.currency != self.currency_map[self.unit]: + raise Exception( + f"Expected currency {self.currency_map[self.unit]}, got {strike_quote.amount.currency}" + ) + amount = Amount.from_float(float(strike_quote.amount.amount), self.unit) + fee = self.fee_int(strike_quote) - amount = Amount.from_float(float(data.get("amount").get("amount")), self.unit) quote = PaymentQuoteResponse( amount=amount, - checking_id=data.get("paymentQuoteId"), - fee=Amount(self.unit, 0), + checking_id=strike_quote.paymentQuoteId, + fee=Amount(self.unit, fee), ) return quote @@ -176,49 +245,42 @@ async def pay_invoice( except Exception: error_message = r.json()["data"]["message"] return PaymentResponse( - ok=None, - checking_id=None, - fee=None, - preimage=None, - error_message=error_message, + result=PaymentResult.FAILED, error_message=error_message ) - data = r.json() - states = {"PENDING": None, "COMPLETED": True, "FAILED": False} - if states[data.get("state")]: - return PaymentResponse( - ok=True, checking_id=None, fee=None, preimage=None, error_message=None - ) - else: - return PaymentResponse( - ok=False, checking_id=None, fee=None, preimage=None, error_message=None - ) + payment = StrikePaymentResponse.parse_obj(r.json()) + fee = self.fee_int(payment) + return PaymentResponse( + result=PAYMENT_RESULT_MAP[payment.state], + checking_id=payment.paymentId, + fee=Amount(self.unit, fee), + ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: try: r = await self.client.get(url=f"{self.endpoint}/v1/invoices/{checking_id}") r.raise_for_status() - except Exception: - return PaymentStatus(paid=None) + except Exception as e: + return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e)) data = r.json() - states = {"PENDING": None, "UNPAID": None, "PAID": True, "CANCELLED": False} - return PaymentStatus(paid=states[data["state"]]) + return PaymentStatus(result=INVOICE_RESULT_MAP[data.get("state")]) async def get_payment_status(self, checking_id: str) -> PaymentStatus: try: r = await self.client.get(url=f"{self.endpoint}/v1/payments/{checking_id}") r.raise_for_status() - except Exception: - return PaymentStatus(paid=None) - data = r.json() - if "paid" not in data and "details" not in data: - return PaymentStatus(paid=None) - - return PaymentStatus( - paid=data["paid"], - fee_msat=data["details"]["fee"], - preimage=data["preimage"], - ) + payment = StrikePaymentResponse.parse_obj(r.json()) + fee = self.fee_int(payment) + return PaymentStatus( + result=PAYMENT_RESULT_MAP[payment.state], + fee=Amount(self.unit, fee), + ) + except httpx.HTTPStatusError as exc: + if exc.response.status_code != 404: + raise exc + return PaymentStatus( + result=PaymentResult.UNKNOWN, error_message=exc.response.text + ) - 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") diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 0ef4af9d..a3f6b1f2 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -201,16 +201,6 @@ async def update_mint_quote( ) -> None: ... - # @abstractmethod - # async def update_mint_quote_paid( - # self, - # *, - # quote_id: str, - # paid: bool, - # db: Database, - # conn: Optional[Connection] = None, - # ) -> None: ... - @abstractmethod async def store_melt_quote( self, @@ -233,6 +223,16 @@ async def get_melt_quote( ) -> Optional[MeltQuote]: ... + @abstractmethod + async def get_melt_quote_by_request( + self, + *, + request: str, + db: Database, + conn: Optional[Connection] = None, + ) -> Optional[MeltQuote]: + ... + @abstractmethod async def update_melt_quote( self, @@ -433,8 +433,8 @@ async def store_mint_quote( await (conn or db).execute( f""" INSERT INTO {db.table_with_schema('mint_quotes')} - (quote, method, request, checking_id, unit, amount, issued, paid, state, created_time, paid_time) - VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :issued, :paid, :state, :created_time, :paid_time) + (quote, method, request, checking_id, unit, amount, issued, state, created_time, paid_time) + VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :issued, :state, :created_time, :paid_time) """, { "quote": quote.quote, @@ -443,8 +443,7 @@ async def store_mint_quote( "checking_id": quote.checking_id, "unit": quote.unit, "amount": quote.amount, - "issued": quote.issued, - "paid": quote.paid, + "issued": quote.issued, # this is deprecated! we need to store it because we have a NOT NULL constraint | we could also remove the column but sqlite doesn't support that (we would have to make a new table) "state": quote.state.name, "created_time": db.to_timestamp( db.timestamp_from_seconds(quote.created_time) or "" @@ -513,10 +512,8 @@ async def update_mint_quote( conn: Optional[Connection] = None, ) -> None: await (conn or db).execute( - f"UPDATE {db.table_with_schema('mint_quotes')} SET issued = :issued, paid = :paid, state = :state, paid_time = :paid_time WHERE quote = :quote", + f"UPDATE {db.table_with_schema('mint_quotes')} SET state = :state, paid_time = :paid_time WHERE quote = :quote", { - "issued": quote.issued, - "paid": quote.paid, "state": quote.state.name, "paid_time": db.to_timestamp( db.timestamp_from_seconds(quote.paid_time) or "" @@ -525,23 +522,6 @@ async def update_mint_quote( }, ) - # async def update_mint_quote_paid( - # self, - # *, - # quote_id: str, - # paid: bool, - # db: Database, - # conn: Optional[Connection] = None, - # ) -> None: - # await (conn or db).execute( - # f"UPDATE {db.table_with_schema('mint_quotes')} SET paid = ? WHERE" - # " quote = ?", - # ( - # paid, - # quote_id, - # ), - # ) - async def store_melt_quote( self, *, @@ -552,8 +532,8 @@ async def store_melt_quote( await (conn or db).execute( f""" INSERT INTO {db.table_with_schema('melt_quotes')} - (quote, method, request, checking_id, unit, amount, fee_reserve, paid, state, created_time, paid_time, fee_paid, proof, change, expiry) - VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :fee_reserve, :paid, :state, :created_time, :paid_time, :fee_paid, :proof, :change, :expiry) + (quote, method, request, checking_id, unit, amount, fee_reserve, state, created_time, paid_time, fee_paid, proof, change, expiry) + VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :fee_reserve, :state, :created_time, :paid_time, :fee_paid, :proof, :change, :expiry) """, { "quote": quote.quote, @@ -563,7 +543,6 @@ async def store_melt_quote( "unit": quote.unit, "amount": quote.amount, "fee_reserve": quote.fee_reserve or 0, - "paid": quote.paid, "state": quote.state.name, "created_time": db.to_timestamp( db.timestamp_from_seconds(quote.created_time) or "" @@ -610,8 +589,22 @@ async def get_melt_quote( """, values, ) - if row is None: - return None + return MeltQuote.from_row(row) if row else None + + async def get_melt_quote_by_request( + self, + *, + request: str, + db: Database, + conn: Optional[Connection] = None, + ) -> Optional[MeltQuote]: + row = await (conn or db).fetchone( + f""" + SELECT * from {db.table_with_schema('melt_quotes')} + WHERE request = :request + """, + {"request": request}, + ) return MeltQuote.from_row(row) if row else None async def update_melt_quote( @@ -623,10 +616,9 @@ async def update_melt_quote( ) -> None: await (conn or db).execute( f""" - UPDATE {db.table_with_schema('melt_quotes')} SET paid = :paid, state = :state, fee_paid = :fee_paid, paid_time = :paid_time, proof = :proof, change = :change WHERE quote = :quote + UPDATE {db.table_with_schema('melt_quotes')} SET state = :state, fee_paid = :fee_paid, paid_time = :paid_time, proof = :proof, change = :change, checking_id = :checking_id WHERE quote = :quote """, { - "paid": quote.paid, "state": quote.state.name, "fee_paid": quote.fee_paid, "paid_time": db.to_timestamp( @@ -637,6 +629,7 @@ async def update_melt_quote( if quote.change else None, "quote": quote.quote, + "checking_id": quote.checking_id, }, ) @@ -678,7 +671,7 @@ async def get_balance( db: Database, conn: Optional[Connection] = None, ) -> int: - row = await (conn or db).fetchone( + row: List = await (conn or db).fetchone( f""" SELECT * from {db.table_with_schema('balance')} """ diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index 242e659d..42a602e0 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -129,9 +129,9 @@ async def _set_mint_quote_pending(self, quote_id: str) -> MintQuote: ) if not quote: raise TransactionError("Mint quote not found.") - if quote.state == MintQuoteState.pending: + if quote.pending: raise TransactionError("Mint quote already pending.") - if not quote.state == MintQuoteState.paid: + if not quote.paid: raise TransactionError("Mint quote is not paid yet.") # set the quote as pending quote.state = MintQuoteState.pending @@ -181,15 +181,15 @@ async def _set_melt_quote_pending(self, quote: MeltQuote) -> MeltQuote: quote_copy = quote.copy() async with self.db.get_connection( lock_table="melt_quotes", - lock_select_statement=f"checking_id='{quote.checking_id}'", + lock_select_statement=f"quote='{quote.quote}'", ) as conn: # get melt quote from db and check if it is already pending quote_db = await self.crud.get_melt_quote( - checking_id=quote.checking_id, db=self.db, conn=conn + quote_id=quote.quote, db=self.db, conn=conn ) if not quote_db: raise TransactionError("Melt quote not found.") - if quote_db.state == MeltQuoteState.pending: + if quote_db.pending: raise TransactionError("Melt quote already pending.") # set the quote as pending quote_copy.state = MeltQuoteState.pending diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 324de902..a48bfc7d 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -50,6 +50,8 @@ InvoiceResponse, LightningBackend, PaymentQuoteResponse, + PaymentResponse, + PaymentResult, PaymentStatus, ) from ..mint.crud import LedgerCrudSqlite @@ -123,12 +125,13 @@ async def _startup_ledger(self): ) status = await self.backends[method][unit].status() if status.error_message: - logger.warning( + logger.error( "The backend for" f" {self.backends[method][unit].__class__.__name__} isn't" f" working properly: '{status.error_message}'", RuntimeWarning, ) + exit(1) logger.info(f"Backend balance: {status.balance} {unit.name}") logger.info(f"Data dir: {settings.cashu_dir}") @@ -148,40 +151,10 @@ async def _check_pending_proofs_and_melt_quotes(self): ) if not melt_quotes: return + logger.info("Checking pending melt quotes") for quote in melt_quotes: - # get pending proofs for quote - pending_proofs = await self.crud.get_pending_proofs_for_quote( - quote_id=quote.quote, db=self.db - ) - # check with the backend whether the quote has been paid during downtime - payment = await self.backends[Method[quote.method]][ - Unit[quote.unit] - ].get_payment_status(quote.checking_id) - if payment.paid: - logger.info(f"Melt quote {quote.quote} state: paid") - quote.paid_time = int(time.time()) - quote.paid = True - quote.state = MeltQuoteState.paid - if payment.fee: - quote.fee_paid = payment.fee.to(Unit[quote.unit]).amount - quote.payment_preimage = payment.preimage or "" - await self.crud.update_melt_quote(quote=quote, db=self.db) - # invalidate proofs - await self._invalidate_proofs( - proofs=pending_proofs, quote_id=quote.quote - ) - # unset pending - await self.db_write._unset_proofs_pending(pending_proofs) - elif payment.failed: - logger.info(f"Melt quote {quote.quote} state: failed") - # unset pending - await self.db_write._unset_proofs_pending(pending_proofs, spent=False) - elif payment.pending: - logger.info(f"Melt quote {quote.quote} state: pending") - pass - else: - logger.error("Melt quote state unknown") - pass + quote = await self.get_melt_quote(quote_id=quote.quote, purge_unknown=True) + logger.info(f"Melt quote {quote.quote} state: {quote.state}") # ------- KEYS ------- @@ -447,8 +420,6 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: checking_id=invoice_response.checking_id, unit=quote_request.unit, amount=quote_request.amount, - issued=False, - paid=False, state=MintQuoteState.unpaid, created_time=int(time.time()), expiry=expiry, @@ -476,14 +447,14 @@ async def get_mint_quote(self, quote_id: str) -> MintQuote: unit, method = self._verify_and_get_unit_method(quote.unit, quote.method) - if quote.state == MintQuoteState.unpaid: + if quote.unpaid: if not quote.checking_id: raise CashuError("quote has no checking id") logger.trace(f"Lightning: checking invoice {quote.checking_id}") status: PaymentStatus = await self.backends[method][ unit ].get_invoice_status(quote.checking_id) - if status.paid: + if status.settled: # change state to paid in one transaction, it could have been marked paid # by the invoice listener in the mean time async with self.db.get_connection( @@ -495,9 +466,8 @@ async def get_mint_quote(self, quote_id: str) -> MintQuote: ) if not quote: raise Exception("quote not found") - if quote.state == MintQuoteState.unpaid: + if quote.unpaid: logger.trace(f"Setting quote {quote_id} as paid") - quote.paid = True quote.state = MintQuoteState.paid quote.paid_time = int(time.time()) await self.crud.update_mint_quote( @@ -537,11 +507,11 @@ async def mint( output_unit = self.keysets[outputs[0].id].unit quote = await self.get_mint_quote(quote_id) - if quote.state == MintQuoteState.pending: + if quote.pending: raise TransactionError("Mint quote already pending.") - if quote.state == MintQuoteState.issued: + if quote.issued: raise TransactionError("Mint quote already issued.") - if not quote.state == MintQuoteState.paid: + if not quote.paid: raise QuoteNotPaidError() previous_state = quote.state await self.db_write._set_mint_quote_pending(quote_id=quote_id) @@ -558,7 +528,6 @@ async def mint( quote_id=quote_id, state=previous_state ) raise e - await self.db_write._unset_mint_quote_pending( quote_id=quote_id, state=MintQuoteState.issued ) @@ -585,10 +554,7 @@ def create_internal_melt_quote( raise TransactionError("mint quote already paid") if mint_quote.issued: raise TransactionError("mint quote already issued") - - if mint_quote.state == MintQuoteState.issued: - raise TransactionError("mint quote already issued") - if mint_quote.state != MintQuoteState.unpaid: + if not mint_quote.unpaid: raise TransactionError("mint quote is not unpaid") if not mint_quote.checking_id: @@ -660,7 +626,6 @@ async def melt_quote( mint_quote = await self.crud.get_mint_quote(request=request, db=self.db) if mint_quote: payment_quote = self.create_internal_melt_quote(mint_quote, melt_quote) - else: # not internal # verify that the backend supports mpp if the quote request has an amount @@ -699,7 +664,6 @@ async def melt_quote( checking_id=payment_quote.checking_id, unit=unit.name, amount=payment_quote.amount.to(unit).amount, - paid=False, state=MeltQuoteState.unpaid, fee_reserve=payment_quote.fee.to(unit).amount, created_time=int(time.time()), @@ -712,20 +676,22 @@ async def melt_quote( quote=quote.quote, amount=quote.amount, fee_reserve=quote.fee_reserve, - paid=quote.paid, + paid=quote.paid, # deprecated state=quote.state.value, expiry=quote.expiry, ) - async def get_melt_quote(self, quote_id: str) -> MeltQuote: + async def get_melt_quote(self, quote_id: str, purge_unknown=False) -> MeltQuote: """Returns a melt quote. - If melt quote is not paid yet and no internal mint quote is associated with it, - checks with the backend for the state of the payment request. If the backend - says that the quote has been paid, updates the melt quote in the database. + If the melt quote is pending, checks status of the payment with the backend. + - If settled, sets the quote as paid and invalidates pending proofs (commit). + - If failed, sets the quote as unpaid and unsets pending proofs (rollback). + - If purge_unknown is set, do the same for unknown states as for failed states. Args: quote_id (str): ID of the melt quote. + purge_unknown (bool, optional): Rollback unknown payment states to unpaid. Defaults to False. Raises: Exception: Quote not found. @@ -743,21 +709,21 @@ async def get_melt_quote(self, quote_id: str) -> MeltQuote: # we only check the state with the backend if there is no associated internal # mint quote for this melt quote - mint_quote = await self.crud.get_mint_quote( + is_internal = await self.crud.get_mint_quote( request=melt_quote.request, db=self.db ) - if not melt_quote.paid and not mint_quote: - logger.trace( + if melt_quote.pending and not is_internal: + logger.debug( "Lightning: checking outgoing Lightning payment" f" {melt_quote.checking_id}" ) status: PaymentStatus = await self.backends[method][ unit ].get_payment_status(melt_quote.checking_id) - if status.paid: - logger.trace(f"Setting quote {quote_id} as paid") - melt_quote.paid = True + logger.debug(f"State: {status.result}") + if status.settled: + logger.debug(f"Setting quote {quote_id} as paid") melt_quote.state = MeltQuoteState.paid if status.fee: melt_quote.fee_paid = status.fee.to(unit).amount @@ -766,6 +732,20 @@ async def get_melt_quote(self, quote_id: str) -> MeltQuote: melt_quote.paid_time = int(time.time()) await self.crud.update_melt_quote(quote=melt_quote, db=self.db) await self.events.submit(melt_quote) + pending_proofs = await self.crud.get_pending_proofs_for_quote( + quote_id=quote_id, db=self.db + ) + await self._invalidate_proofs(proofs=pending_proofs, quote_id=quote_id) + await self.db_write._unset_proofs_pending(pending_proofs) + if status.failed or (purge_unknown and status.unknown): + logger.debug(f"Setting quote {quote_id} as unpaid") + melt_quote.state = MeltQuoteState.unpaid + await self.crud.update_melt_quote(quote=melt_quote, db=self.db) + await self.events.submit(melt_quote) + pending_proofs = await self.crud.get_pending_proofs_for_quote( + quote_id=quote_id, db=self.db + ) + await self.db_write._unset_proofs_pending(pending_proofs) return melt_quote @@ -798,8 +778,6 @@ async def melt_mint_settle_internally( # we settle the transaction internally if melt_quote.paid: raise TransactionError("melt quote already paid") - if melt_quote.state != MeltQuoteState.unpaid: - raise TransactionError("melt quote already paid") # verify amounts from bolt11 invoice bolt11_request = melt_quote.request @@ -830,11 +808,9 @@ async def melt_mint_settle_internally( ) melt_quote.fee_paid = 0 # no internal fees - melt_quote.paid = True melt_quote.state = MeltQuoteState.paid melt_quote.paid_time = int(time.time()) - mint_quote.paid = True mint_quote.state = MintQuoteState.paid mint_quote.paid_time = melt_quote.paid_time @@ -869,14 +845,13 @@ async def melt( """ # get melt quote and check if it was already paid melt_quote = await self.get_melt_quote(quote_id=quote) + if not melt_quote.unpaid: + raise TransactionError(f"melt quote is not unpaid: {melt_quote.state}") unit, method = self._verify_and_get_unit_method( melt_quote.unit, melt_quote.method ) - if melt_quote.state != MeltQuoteState.unpaid: - raise TransactionError("melt quote already paid") - # make sure that the outputs (for fee return) are in the same unit as the quote if outputs: # _verify_outputs checks if all outputs have the same unit @@ -917,36 +892,91 @@ async def melt( await self.db_write._verify_spent_proofs_and_set_pending( proofs, quote_id=melt_quote.quote ) + previous_state = melt_quote.state + melt_quote = await self.db_write._set_melt_quote_pending(melt_quote) try: - # settle the transaction internally if there is a mint quote with the same payment request + # if the melt corresponds to an internal mint, mark both as paid melt_quote = await self.melt_mint_settle_internally(melt_quote, proofs) # quote not paid yet (not internal), pay it with the backend - if not melt_quote.paid and melt_quote.state == MeltQuoteState.unpaid: + if not melt_quote.paid: logger.debug(f"Lightning: pay invoice {melt_quote.request}") - payment = await self.backends[method][unit].pay_invoice( - melt_quote, melt_quote.fee_reserve * 1000 - ) - logger.debug( - f"Melt – Ok: {payment.ok}: preimage: {payment.preimage}," - f" fee: {payment.fee.str() if payment.fee is not None else 'None'}" - ) - if not payment.ok: - raise LightningError( - f"Lightning payment unsuccessful. {payment.error_message}" + try: + payment = await self.backends[method][unit].pay_invoice( + melt_quote, melt_quote.fee_reserve * 1000 ) - if payment.fee: - melt_quote.fee_paid = payment.fee.to( - to_unit=unit, round="up" - ).amount - if payment.preimage: - melt_quote.payment_preimage = payment.preimage - # set quote as paid - melt_quote.paid = True - melt_quote.state = MeltQuoteState.paid - melt_quote.paid_time = int(time.time()) + logger.debug( + f"Melt – Result: {str(payment.result)}: preimage: {payment.preimage}," + f" fee: {payment.fee.str() if payment.fee is not None else 'None'}" + ) + if ( + payment.checking_id + and payment.checking_id != melt_quote.checking_id + ): + logger.warning( + f"pay_invoice returned different checking_id: {payment.checking_id} than melt quote: {melt_quote.checking_id}. Will use it for potentially checking payment status later." + ) + melt_quote.checking_id = payment.checking_id + await self.crud.update_melt_quote(quote=melt_quote, db=self.db) + + except Exception as e: + logger.error(f"Exception during pay_invoice: {e}") + payment = PaymentResponse( + result=PaymentResult.UNKNOWN, + error_message=str(e), + ) + + match payment.result: + case PaymentResult.FAILED | PaymentResult.UNKNOWN: + # explicitly check payment status for failed or unknown payment states + checking_id = payment.checking_id or melt_quote.checking_id + logger.debug( + f"Payment state is {payment.result}. Checking status for {checking_id}" + ) + try: + status = await self.backends[method][ + unit + ].get_payment_status(checking_id) + except Exception as e: + # Something went wrong, better to keep the proofs in pending state + logger.error( + f"Lightning backend error: could not check payment status. Proofs for melt quote {melt_quote.quote} are stuck as PENDING. Error: {e}" + ) + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + match status.result: + case PaymentResult.FAILED | PaymentResult.UNKNOWN: + # NOTE: We only throw a payment error if the payment AND a subsequent status check failed + raise LightningError( + f"Lightning payment failed: {payment.error_message}. Error: {status.error_message}" + ) + case _: + logger.error( + f"Payment state is {status.result} and payment was {payment.result}. Proofs for melt quote {melt_quote.quote} are stuck as PENDING." + ) + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + case PaymentResult.SETTLED: + # payment successful + if payment.fee: + melt_quote.fee_paid = payment.fee.to( + to_unit=unit, round="up" + ).amount + if payment.preimage: + melt_quote.payment_preimage = payment.preimage + # set quote as paid + melt_quote.state = MeltQuoteState.paid + melt_quote.paid_time = int(time.time()) + # NOTE: This is the only return point for a successful payment + + case PaymentResult.PENDING | _: + logger.debug( + f"Lightning payment is pending: {payment.checking_id}" + ) + return PostMeltQuoteResponse.from_melt_quote(melt_quote) # melt successful, invalidate proofs await self._invalidate_proofs(proofs=proofs, quote_id=melt_quote.quote) + await self.db_write._unset_proofs_pending(proofs) # prepare change to compensate wallet for overpaid fees return_promises: List[BlindedSignature] = [] @@ -963,14 +993,15 @@ async def melt( await self.crud.update_melt_quote(quote=melt_quote, db=self.db) await self.events.submit(melt_quote) + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + except Exception as e: - logger.trace(f"Melt exception: {e}") - raise e - finally: - # delete proofs from pending list + logger.trace(f"Payment has failed: {e}") await self.db_write._unset_proofs_pending(proofs) - - return PostMeltQuoteResponse.from_melt_quote(melt_quote) + await self.db_write._unset_melt_quote_pending( + quote=melt_quote, state=previous_state + ) + raise e async def swap( self, diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 88ec94eb..58cc35b6 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -1,4 +1,5 @@ import copy +from typing import Dict, List from ..core.base import MintKeyset, Proof from ..core.crypto.keys import derive_keyset_id, derive_keyset_id_deprecated @@ -287,7 +288,6 @@ async def m011_add_quote_tables(db: Database): checking_id TEXT NOT NULL, unit TEXT NOT NULL, amount {db.big_int} NOT NULL, - paid BOOL NOT NULL, issued BOOL NOT NULL, created_time TIMESTAMP, paid_time TIMESTAMP, @@ -296,6 +296,7 @@ async def m011_add_quote_tables(db: Database): ); """ + # NOTE: We remove the paid BOOL NOT NULL column ) await conn.execute( @@ -308,7 +309,6 @@ async def m011_add_quote_tables(db: Database): unit TEXT NOT NULL, amount {db.big_int} NOT NULL, fee_reserve {db.big_int}, - paid BOOL NOT NULL, created_time TIMESTAMP, paid_time TIMESTAMP, fee_paid {db.big_int}, @@ -318,13 +318,14 @@ async def m011_add_quote_tables(db: Database): ); """ + # NOTE: We remove the paid BOOL NOT NULL column ) await conn.execute( f"INSERT INTO {db.table_with_schema('mint_quotes')} (quote, method," - " request, checking_id, unit, amount, paid, issued, created_time," + " request, checking_id, unit, amount, issued, created_time," " paid_time) SELECT id, 'bolt11', bolt11, COALESCE(payment_hash, 'None')," - f" 'sat', amount, False, issued, COALESCE(created, '{db.timestamp_now_str()}')," + f" 'sat', amount, issued, COALESCE(created, '{db.timestamp_now_str()}')," f" NULL FROM {db.table_with_schema('invoices')} " ) @@ -788,13 +789,13 @@ async def m020_add_state_to_mint_and_melt_quotes(db: Database): # and the `paid` and `issued` column respectively # mint quotes: async with db.connect() as conn: - rows = await conn.fetchall( + rows: List[Dict] = await conn.fetchall( f"SELECT * FROM {db.table_with_schema('mint_quotes')}" ) for row in rows: - if row["issued"]: + if row.get("issued"): state = "issued" - elif row["paid"]: + elif row.get("paid"): state = "paid" else: state = "unpaid" @@ -804,10 +805,10 @@ async def m020_add_state_to_mint_and_melt_quotes(db: Database): # melt quotes: async with db.connect() as conn: - rows = await conn.fetchall( + rows2: List[Dict] = await conn.fetchall( f"SELECT * FROM {db.table_with_schema('melt_quotes')}" ) - for row in rows: + for row in rows2: if row["paid"]: state = "paid" else: diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 1c40a332..f52e58da 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -168,7 +168,7 @@ async def mint_quote( resp = PostMintQuoteResponse( request=quote.request, quote=quote.quote, - paid=quote.paid, + paid=quote.paid, # deprecated state=quote.state.value, expiry=quote.expiry, ) @@ -192,7 +192,7 @@ async def get_mint_quote(request: Request, quote: str) -> PostMintQuoteResponse: resp = PostMintQuoteResponse( quote=mint_quote.quote, request=mint_quote.request, - paid=mint_quote.paid, + paid=mint_quote.paid, # deprecated state=mint_quote.state.value, expiry=mint_quote.expiry, ) diff --git a/cashu/mint/router_deprecated.py b/cashu/mint/router_deprecated.py index 95d6ee26..d09c2f72 100644 --- a/cashu/mint/router_deprecated.py +++ b/cashu/mint/router_deprecated.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Request from loguru import logger -from ..core.base import BlindedMessage, BlindedSignature, ProofSpentState +from ..core.base import BlindedMessage, BlindedSignature from ..core.errors import CashuError from ..core.models import ( CheckFeesRequest_deprecated, @@ -345,13 +345,13 @@ async def check_spendable_deprecated( spendableList: List[bool] = [] pendingList: List[bool] = [] for proof_state in proofs_state: - if proof_state.state == ProofSpentState.unspent: + if proof_state.unspent: spendableList.append(True) pendingList.append(False) - elif proof_state.state == ProofSpentState.spent: + elif proof_state.spent: spendableList.append(False) pendingList.append(False) - elif proof_state.state == ProofSpentState.pending: + elif proof_state.pending: spendableList.append(True) pendingList.append(True) return CheckSpendableResponse_deprecated( diff --git a/cashu/mint/tasks.py b/cashu/mint/tasks.py index 2ce0541f..a62e75db 100644 --- a/cashu/mint/tasks.py +++ b/cashu/mint/tasks.py @@ -56,8 +56,7 @@ async def invoice_callback_dispatcher(self, checking_id: str) -> None: f"Invoice callback dispatcher: quote {quote} trying to set as {MintQuoteState.paid}" ) # set the quote as paid - if quote.state == MintQuoteState.unpaid: - quote.paid = True + if quote.unpaid: quote.state = MintQuoteState.paid await self.crud.update_mint_quote(quote=quote, db=self.db, conn=conn) logger.trace( diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index d41c45cb..1f994d53 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -222,7 +222,7 @@ async def pay( print(" Error: Balance too low.") return send_proofs, fees = await wallet.select_to_send( - wallet.proofs, total_amount, include_fees=True + wallet.proofs, total_amount, include_fees=True, set_reserved=True ) try: melt_response = await wallet.melt( @@ -231,11 +231,25 @@ async def pay( except Exception as e: print(f" Error paying invoice: {str(e)}") return - print(" Invoice paid", end="", flush=True) - if melt_response.payment_preimage and melt_response.payment_preimage != "0" * 64: - print(f" (Preimage: {melt_response.payment_preimage}).") + if ( + melt_response.state + and MintQuoteState(melt_response.state) == MintQuoteState.paid + ): + print(" Invoice paid", end="", flush=True) + if ( + melt_response.payment_preimage + and melt_response.payment_preimage != "0" * 64 + ): + print(f" (Preimage: {melt_response.payment_preimage}).") + else: + print(".") + elif MintQuoteState(melt_response.state) == MintQuoteState.pending: + print(" Invoice pending.") + elif MintQuoteState(melt_response.state) == MintQuoteState.unpaid: + print(" Invoice unpaid.") else: - print(".") + print(" Error paying invoice.") + await print_balance(ctx) diff --git a/cashu/wallet/lightning/lightning.py b/cashu/wallet/lightning/lightning.py index 3e5489d7..a3bb2805 100644 --- a/cashu/wallet/lightning/lightning.py +++ b/cashu/wallet/lightning/lightning.py @@ -2,12 +2,13 @@ import bolt11 -from ...core.base import Amount, ProofSpentState, Unit +from ...core.base import Amount, Unit from ...core.helpers import sum_promises from ...core.settings import settings from ...lightning.base import ( InvoiceResponse, PaymentResponse, + PaymentResult, PaymentStatus, StatusResponse, ) @@ -58,14 +59,14 @@ async def pay_invoice(self, pr: str) -> PaymentResponse: pr (str): bolt11 payment request Returns: - bool: True if successful + PaymentResponse: containing details of the operation """ quote = await self.melt_quote(pr) total_amount = quote.amount + quote.fee_reserve assert total_amount > 0, "amount is not positive" if self.available_balance < total_amount: print("Error: Balance too low.") - return PaymentResponse(ok=False) + return PaymentResponse(result=PaymentResult.FAILED) _, send_proofs = await self.swap_to_send(self.proofs, total_amount) try: resp = await self.melt(send_proofs, pr, quote.fee_reserve, quote.quote) @@ -76,14 +77,14 @@ async def pay_invoice(self, pr: str) -> PaymentResponse: invoice_obj = bolt11.decode(pr) return PaymentResponse( - ok=True, + result=PaymentResult.SETTLED, checking_id=invoice_obj.payment_hash, preimage=resp.payment_preimage, fee=Amount(Unit.msat, fees_paid_sat), ) except Exception as e: print("Exception:", e) - return PaymentResponse(ok=False, error_message=str(e)) + return PaymentResponse(result=PaymentResult.FAILED, error_message=str(e)) async def get_invoice_status(self, payment_hash: str) -> PaymentStatus: """Get lightning invoice status (incoming) @@ -98,16 +99,16 @@ async def get_invoice_status(self, payment_hash: str) -> PaymentStatus: db=self.db, payment_hash=payment_hash, out=False ) if not invoice: - return PaymentStatus(paid=None) + return PaymentStatus(result=PaymentResult.UNKNOWN) if invoice.paid: - return PaymentStatus(paid=True) + return PaymentStatus(result=PaymentResult.SETTLED) try: # to check the invoice state, we try minting tokens await self.mint(invoice.amount, id=invoice.id) - return PaymentStatus(paid=True) + return PaymentStatus(result=PaymentResult.SETTLED) except Exception as e: print(e) - return PaymentStatus(paid=False) + return PaymentStatus(result=PaymentResult.FAILED) async def get_payment_status(self, payment_hash: str) -> PaymentStatus: """Get lightning payment status (outgoing) @@ -126,24 +127,30 @@ async def get_payment_status(self, payment_hash: str) -> PaymentStatus: ) if not invoice: - return PaymentStatus(paid=False) # "invoice not found (in db)" + return PaymentStatus( + result=PaymentResult.FAILED + ) # "invoice not found (in db)" if invoice.paid: - return PaymentStatus(paid=True, preimage=invoice.preimage) # "paid (in db)" + return PaymentStatus( + result=PaymentResult.SETTLED, preimage=invoice.preimage + ) # "paid (in db)" proofs = await get_proofs(db=self.db, melt_id=invoice.id) if not proofs: - return PaymentStatus(paid=False) # "proofs not fount (in db)" + return PaymentStatus( + result=PaymentResult.FAILED + ) # "proofs not fount (in db)" proofs_states = await self.check_proof_state(proofs) if not proofs_states: - return PaymentStatus(paid=False) # "states not fount" + return PaymentStatus(result=PaymentResult.FAILED) # "states not fount" - if all([p.state == ProofSpentState.pending for p in proofs_states.states]): - return PaymentStatus(paid=None) # "pending (with check)" - if any([p.state == ProofSpentState.spent for p in proofs_states.states]): + if all([p.state.pending for p in proofs_states.states]): + return PaymentStatus(result=PaymentResult.PENDING) # "pending (with check)" + if any([p.state.spent for p in proofs_states.states]): # NOTE: consider adding this check in wallet.py and mark the invoice as paid if all proofs are spent - return PaymentStatus(paid=True) # "paid (with check)" - if all([p.state == ProofSpentState.unspent for p in proofs_states.states]): - return PaymentStatus(paid=False) # "failed (with check)" - return PaymentStatus(paid=None) # "undefined state" + return PaymentStatus(result=PaymentResult.SETTLED) # "paid (with check)" + if all([p.state.unspent for p in proofs_states.states]): + return PaymentStatus(result=PaymentResult.FAILED) # "failed (with check)" + return PaymentStatus(result=PaymentResult.UNKNOWN) # "undefined state" async def get_balance(self) -> StatusResponse: """Get lightning balance diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 20f9839e..7640baf7 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -13,8 +13,8 @@ BlindedSignature, DLEQWallet, Invoice, + MeltQuoteState, Proof, - ProofSpentState, Unit, WalletKeyset, ) @@ -759,15 +759,18 @@ async def melt( status = await super().melt(quote_id, proofs, change_outputs) # if payment fails - if not status.paid: - # remove the melt_id in proofs + if MeltQuoteState(status.state) == MeltQuoteState.unpaid: + # remove the melt_id in proofs and set reserved to False for p in proofs: p.melt_id = None - await update_proof(p, melt_id=None, db=self.db) + p.reserved = False + await update_proof(p, melt_id="", db=self.db) raise Exception("could not pay invoice.") + elif MeltQuoteState(status.state) == MeltQuoteState.pending: + # payment is still pending + return status # invoice was paid successfully - await self.invalidate(proofs) # update paid status in db @@ -995,7 +998,7 @@ async def invalidate( if check_spendable: proof_states = await self.check_proof_state(proofs) for i, state in enumerate(proof_states.states): - if state.state == ProofSpentState.spent: + if state.spent: invalidated_proofs.append(proofs[i]) else: invalidated_proofs = proofs diff --git a/mypy.ini b/mypy.ini index 087ac3c0..e29d7de5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.9 +python_version = 3.10 # disallow_untyped_defs = True ; check_untyped_defs = True ignore_missing_imports = True diff --git a/tests/conftest.py b/tests/conftest.py index 76d39d30..4a2353fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,7 +48,7 @@ settings.mint_max_balance = 0 settings.mint_transaction_rate_limit_per_minute = 60 settings.mint_lnd_enable_mpp = True -settings.mint_clnrest_enable_mpp = False +settings.mint_clnrest_enable_mpp = True settings.mint_input_fee_ppk = 0 settings.db_connection_pool = True diff --git a/tests/helpers.py b/tests/helpers.py index 5f4efe49..d769b190 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -96,6 +96,7 @@ async def get_random_invoice_data(): "--rpcserver=lnd-2", ] + def docker_clightning_cli(index): return [ "docker", @@ -104,9 +105,9 @@ def docker_clightning_cli(index): "lightning-cli", "--network", "regtest", - "--keywords", ] + def run_cmd(cmd: list) -> str: timeout = 20 process = Popen(cmd, stdout=PIPE, stderr=PIPE) @@ -171,11 +172,22 @@ def pay_real_invoice(invoice: str) -> str: cmd.extend(["payinvoice", "--force", invoice]) return run_cmd(cmd) + def partial_pay_real_invoice(invoice: str, amount: int, node: int) -> str: cmd = docker_clightning_cli(node) cmd.extend(["pay", f"bolt11={invoice}", f"partial_msat={amount*1000}"]) return run_cmd(cmd) + +def get_real_invoice_cln(sats: int) -> str: + cmd = docker_clightning_cli(1) + cmd.extend( + ["invoice", f"{sats*1000}", hashlib.sha256(os.urandom(32)).hexdigest(), "test"] + ) + result = run_cmd_json(cmd) + return result["bolt11"] + + def mine_blocks(blocks: int = 1) -> str: cmd = docker_bitcoin_cli.copy() cmd.extend(["-generate", str(blocks)]) diff --git a/tests/test_db.py b/tests/test_db.py index d44804ff..5d686630 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -2,7 +2,7 @@ import datetime import os import time -from typing import List +from typing import List, Tuple import pytest import pytest_asyncio @@ -63,7 +63,8 @@ async def test_db_tables(ledger: Ledger): "SELECT table_name FROM information_schema.tables WHERE table_schema =" " 'public';" ) - tables = [t[0] for t in tables_res.all()] + tables_all: List[Tuple[str]] = tables_res.all() + tables = [t[0] for t in tables_all] tables_expected = [ "dbversions", "keysets", diff --git a/tests/test_mint_api.py b/tests/test_mint_api.py index 627fd60f..aa5b402f 100644 --- a/tests/test_mint_api.py +++ b/tests/test_mint_api.py @@ -3,7 +3,7 @@ import pytest import pytest_asyncio -from cashu.core.base import MeltQuoteState, MintQuoteState, ProofSpentState +from cashu.core.base import MeltQuoteState, MintQuoteState from cashu.core.models import ( GetInfoResponse, MintMethodSetting, @@ -478,7 +478,7 @@ async def test_api_check_state(ledger: Ledger): response = PostCheckStateResponse.parse_obj(response.json()) assert response assert len(response.states) == 2 - assert response.states[0].state == ProofSpentState.unspent + assert response.states[0].state.unspent @pytest.mark.asyncio diff --git a/tests/test_mint_db.py b/tests/test_mint_db.py index ffaab5a9..2dc7bb1d 100644 --- a/tests/test_mint_db.py +++ b/tests/test_mint_db.py @@ -1,7 +1,7 @@ import pytest import pytest_asyncio -from cashu.core.base import MeltQuoteState, MintQuoteState, ProofSpentState +from cashu.core.base import MeltQuoteState, MintQuoteState from cashu.core.models import PostMeltQuoteRequest from cashu.mint.ledger import Ledger from cashu.wallet.wallet import Wallet @@ -35,14 +35,12 @@ async def test_mint_proofs_pending(wallet1: Wallet, ledger: Ledger): proofs = wallet1.proofs.copy() proofs_states_before_split = await wallet1.check_proof_state(proofs) - assert all( - [s.state == ProofSpentState.unspent for s in proofs_states_before_split.states] - ) + assert all([s.unspent for s in proofs_states_before_split.states]) await ledger.db_write._verify_spent_proofs_and_set_pending(proofs) proof_states = await wallet1.check_proof_state(proofs) - assert all([s.state == ProofSpentState.pending for s in proof_states.states]) + assert all([s.pending for s in proof_states.states]) await assert_err(wallet1.split(wallet1.proofs, 20), "proofs are pending.") await ledger.db_write._unset_proofs_pending(proofs) @@ -50,9 +48,7 @@ async def test_mint_proofs_pending(wallet1: Wallet, ledger: Ledger): await wallet1.split(proofs, 20) proofs_states_after_split = await wallet1.check_proof_state(proofs) - assert all( - [s.state == ProofSpentState.spent for s in proofs_states_after_split.states] - ) + assert all([s.spent for s in proofs_states_after_split.states]) @pytest.mark.asyncio @@ -77,7 +73,7 @@ async def test_mint_quote_state_transitions(wallet1: Wallet, ledger: Ledger): quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db) assert quote is not None assert quote.quote == invoice.id - assert quote.state == MintQuoteState.unpaid + assert quote.unpaid # set pending again async def set_state(quote, state): @@ -167,12 +163,12 @@ async def test_melt_quote_set_pending(wallet1: Wallet, ledger: Ledger): quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db) assert quote is not None assert quote.quote == melt_quote.quote - assert quote.state == MeltQuoteState.unpaid + assert quote.unpaid previous_state = quote.state await ledger.db_write._set_melt_quote_pending(quote) quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db) assert quote is not None - assert quote.state == MeltQuoteState.pending + assert quote.pending # set unpending await ledger.db_write._unset_melt_quote_pending(quote, previous_state) @@ -191,7 +187,7 @@ async def test_melt_quote_state_transitions(wallet1: Wallet, ledger: Ledger): quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db) assert quote is not None assert quote.quote == melt_quote.quote - assert quote.state == MeltQuoteState.unpaid + assert quote.unpaid # set pending quote.state = MeltQuoteState.pending @@ -218,7 +214,7 @@ async def test_mint_quote_set_pending(wallet1: Wallet, ledger: Ledger): assert invoice is not None quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db) assert quote is not None - assert quote.state == MintQuoteState.unpaid + assert quote.unpaid # pay_if_regtest pays on regtest, get_mint_quote pays on FakeWallet await pay_if_regtest(invoice.bolt11) @@ -226,13 +222,13 @@ async def test_mint_quote_set_pending(wallet1: Wallet, ledger: Ledger): quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db) assert quote is not None - assert quote.state == MintQuoteState.paid + assert quote.paid previous_state = MintQuoteState.paid await ledger.db_write._set_mint_quote_pending(quote.quote) quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db) assert quote is not None - assert quote.state == MintQuoteState.pending + assert quote.pending # try to mint while pending await assert_err(wallet1.mint(128, id=invoice.id), "Mint quote already pending.") @@ -243,7 +239,7 @@ async def test_mint_quote_set_pending(wallet1: Wallet, ledger: Ledger): quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db) assert quote is not None assert quote.state == previous_state - assert quote.state == MintQuoteState.paid + assert quote.paid # # set paid and mint again # quote.state = MintQuoteState.paid @@ -254,7 +250,7 @@ async def test_mint_quote_set_pending(wallet1: Wallet, ledger: Ledger): # check if quote is issued quote = await ledger.crud.get_mint_quote(quote_id=invoice.id, db=ledger.db) assert quote is not None - assert quote.state == MintQuoteState.issued + assert quote.issued @pytest.mark.asyncio diff --git a/tests/test_mint_init.py b/tests/test_mint_init.py index 9c9ae7fa..71aa7021 100644 --- a/tests/test_mint_init.py +++ b/tests/test_mint_init.py @@ -5,10 +5,11 @@ import pytest import pytest_asyncio -from cashu.core.base import MeltQuote, MeltQuoteState, Proof, ProofSpentState +from cashu.core.base import MeltQuote, MeltQuoteState, Proof from cashu.core.crypto.aes import AESCipher from cashu.core.db import Database from cashu.core.settings import settings +from cashu.lightning.base import PaymentResult from cashu.mint.crud import LedgerCrudSqlite from cashu.mint.ledger import Ledger from cashu.wallet.wallet import Wallet @@ -143,8 +144,7 @@ async def create_pending_melts( request="asdasd", checking_id=check_id, unit="sat", - paid=False, - state=MeltQuoteState.unpaid, + state=MeltQuoteState.pending, amount=100, fee_reserve=1, ) @@ -173,8 +173,8 @@ async def test_startup_fakewallet_pending_quote_success(ledger: Ledger): after the startup routine determines that the associated melt quote was paid.""" pending_proof, quote = await create_pending_melts(ledger) states = await ledger.db_read.get_proofs_states([pending_proof.Y]) - assert states[0].state == ProofSpentState.pending - settings.fakewallet_payment_state = True + assert states[0].pending + settings.fakewallet_payment_state = PaymentResult.SETTLED.name # run startup routinge await ledger.startup_ledger() @@ -186,7 +186,7 @@ async def test_startup_fakewallet_pending_quote_success(ledger: Ledger): # expect that proofs are spent states = await ledger.db_read.get_proofs_states([pending_proof.Y]) - assert states[0].state == ProofSpentState.spent + assert states[0].spent @pytest.mark.asyncio @@ -199,8 +199,8 @@ async def test_startup_fakewallet_pending_quote_failure(ledger: Ledger): """ pending_proof, quote = await create_pending_melts(ledger) states = await ledger.db_read.get_proofs_states([pending_proof.Y]) - assert states[0].state == ProofSpentState.pending - settings.fakewallet_payment_state = False + assert states[0].pending + settings.fakewallet_payment_state = PaymentResult.FAILED.name # run startup routinge await ledger.startup_ledger() @@ -212,7 +212,7 @@ async def test_startup_fakewallet_pending_quote_failure(ledger: Ledger): # expect that proofs are unspent states = await ledger.db_read.get_proofs_states([pending_proof.Y]) - assert states[0].state == ProofSpentState.unspent + assert states[0].unspent @pytest.mark.asyncio @@ -220,8 +220,8 @@ async def test_startup_fakewallet_pending_quote_failure(ledger: Ledger): async def test_startup_fakewallet_pending_quote_pending(ledger: Ledger): pending_proof, quote = await create_pending_melts(ledger) states = await ledger.db_read.get_proofs_states([pending_proof.Y]) - assert states[0].state == ProofSpentState.pending - settings.fakewallet_payment_state = None + assert states[0].pending + settings.fakewallet_payment_state = PaymentResult.PENDING.name # run startup routinge await ledger.startup_ledger() @@ -233,7 +233,28 @@ async def test_startup_fakewallet_pending_quote_pending(ledger: Ledger): # expect that proofs are still pending states = await ledger.db_read.get_proofs_states([pending_proof.Y]) - assert states[0].state == ProofSpentState.pending + assert states[0].pending + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_regtest, reason="only for fake wallet") +async def test_startup_fakewallet_pending_quote_unknown(ledger: Ledger): + pending_proof, quote = await create_pending_melts(ledger) + states = await ledger.db_read.get_proofs_states([pending_proof.Y]) + assert states[0].pending + settings.fakewallet_payment_state = PaymentResult.UNKNOWN.name + # run startup routinge + await ledger.startup_ledger() + + # expect that melt quote is still pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert not melt_quotes + + # expect that proofs are still pending + states = await ledger.db_read.get_proofs_states([pending_proof.Y]) + assert states[0].unspent @pytest.mark.asyncio @@ -262,7 +283,6 @@ async def test_startup_regtest_pending_quote_pending(wallet: Wallet, ledger: Led ) ) await asyncio.sleep(SLEEP_TIME) - # settle_invoice(preimage=preimage) # run startup routinge await ledger.startup_ledger() @@ -275,7 +295,7 @@ async def test_startup_regtest_pending_quote_pending(wallet: Wallet, ledger: Led # expect that proofs are still pending states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == ProofSpentState.pending for s in states]) + assert all([s.pending for s in states]) # only now settle the invoice settle_invoice(preimage=preimage) @@ -309,7 +329,7 @@ async def test_startup_regtest_pending_quote_success(wallet: Wallet, ledger: Led await asyncio.sleep(SLEEP_TIME) # expect that proofs are pending states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == ProofSpentState.pending for s in states]) + assert all([s.pending for s in states]) settle_invoice(preimage=preimage) await asyncio.sleep(SLEEP_TIME) @@ -325,7 +345,7 @@ async def test_startup_regtest_pending_quote_success(wallet: Wallet, ledger: Led # expect that proofs are spent states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == ProofSpentState.spent for s in states]) + assert all([s.spent for s in states]) @pytest.mark.asyncio @@ -360,7 +380,7 @@ async def test_startup_regtest_pending_quote_failure(wallet: Wallet, ledger: Led # expect that proofs are pending states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == ProofSpentState.pending for s in states]) + assert all([s.pending for s in states]) cancel_invoice(preimage_hash=preimage_hash) await asyncio.sleep(SLEEP_TIME) @@ -376,4 +396,72 @@ async def test_startup_regtest_pending_quote_failure(wallet: Wallet, ledger: Led # expect that proofs are unspent states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == ProofSpentState.unspent for s in states]) + assert all([s.unspent for s in states]) + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_startup_regtest_pending_quote_unknown(wallet: Wallet, ledger: Ledger): + """Simulate an unknown payment by executing a pending payment, then + manipulating the melt_quote in the mint's db so that its checking_id + points to an unknown payment.""" + + # fill wallet + invoice = await wallet.request_mint(64) + await pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create hodl invoice + preimage, invoice_dict = get_hold_invoice(16) + invoice_payment_request = str(invoice_dict["payment_request"]) + invoice_obj = bolt11.decode(invoice_payment_request) + preimage_hash = invoice_obj.payment_hash + + # wallet pays the invoice + quote = await wallet.melt_quote(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + _, send_proofs = await wallet.swap_to_send(wallet.proofs, total_amount) + asyncio.create_task( + wallet.melt( + proofs=send_proofs, + invoice=invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, + ) + ) + await asyncio.sleep(SLEEP_TIME) + + # expect that proofs are pending + states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) + assert all([s.pending for s in states]) + + # before we cancel the payment, we manipulate the melt_quote's checking_id so + # that the mint fails to look up the payment and treats the payment as failed during startup + melt_quote = await ledger.crud.get_melt_quote_by_request( + db=ledger.db, request=invoice_payment_request + ) + assert melt_quote + assert melt_quote.pending + + # manipulate the checking_id 32 bytes hexadecmial + melt_quote.checking_id = "a" * 64 + await ledger.crud.update_melt_quote(quote=melt_quote, db=ledger.db) + + await asyncio.sleep(SLEEP_TIME) + + # run startup routinge + await ledger.startup_ledger() + + # expect that no melt quote is pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert not melt_quotes + + # expect that proofs are unspent + states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) + assert all([s.unspent for s in states]) + + # clean up + cancel_invoice(preimage_hash=preimage_hash) diff --git a/tests/test_mint_lightning_blink.py b/tests/test_mint_lightning_blink.py index b699c7ab..44613586 100644 --- a/tests/test_mint_lightning_blink.py +++ b/tests/test_mint_lightning_blink.py @@ -98,11 +98,10 @@ async def test_blink_pay_invoice(): unit="sat", amount=100, fee_reserve=12, - paid=False, state=MeltQuoteState.unpaid, ) payment = await blink.pay_invoice(quote, 1000) - assert payment.ok + assert payment.settled assert payment.fee assert payment.fee.amount == 10 assert payment.error_message is None @@ -131,11 +130,10 @@ async def test_blink_pay_invoice_failure(): unit="sat", amount=100, fee_reserve=12, - paid=False, state=MeltQuoteState.unpaid, ) payment = await blink.pay_invoice(quote, 1000) - assert not payment.ok + assert not payment.settled assert payment.fee is None assert payment.error_message assert "This is the error" in payment.error_message @@ -155,7 +153,7 @@ async def test_blink_get_invoice_status(): } respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) status = await blink.get_invoice_status("123") - assert status.paid + assert status.settled @respx.mock @@ -183,7 +181,7 @@ async def test_blink_get_payment_status(): } respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) status = await blink.get_payment_status(payment_request) - assert status.paid + assert status.settled assert status.fee assert status.fee.amount == 10 assert status.preimage == "123" diff --git a/tests/test_mint_melt.py b/tests/test_mint_melt.py new file mode 100644 index 00000000..1a99f205 --- /dev/null +++ b/tests/test_mint_melt.py @@ -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]) diff --git a/tests/test_mint_operations.py b/tests/test_mint_operations.py index acfd9134..a3fd3fd7 100644 --- a/tests/test_mint_operations.py +++ b/tests/test_mint_operations.py @@ -1,7 +1,7 @@ import pytest import pytest_asyncio -from cashu.core.base import MeltQuoteState, MintQuoteState +from cashu.core.base import MeltQuoteState from cashu.core.helpers import sum_proofs from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest from cashu.mint.ledger import Ledger @@ -38,9 +38,8 @@ async def wallet1(ledger: Ledger): async def test_melt_internal(wallet1: Wallet, ledger: Ledger): # mint twice so we have enough to pay the second invoice back invoice = await wallet1.request_mint(128) - await pay_if_regtest(invoice.bolt11) + await ledger.get_mint_quote(invoice.id) await wallet1.mint(128, id=invoice.id) - await pay_if_regtest(invoice.bolt11) assert wallet1.balance == 128 # create a mint quote so that we can melt to it internally @@ -58,14 +57,14 @@ async def test_melt_internal(wallet1: Wallet, ledger: Ledger): melt_quote_pre_payment = await ledger.get_melt_quote(melt_quote.quote) assert not melt_quote_pre_payment.paid, "melt quote should not be paid" - assert melt_quote_pre_payment.state == MeltQuoteState.unpaid + assert melt_quote_pre_payment.unpaid keep_proofs, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 64) await ledger.melt(proofs=send_proofs, quote=melt_quote.quote) melt_quote_post_payment = await ledger.get_melt_quote(melt_quote.quote) assert melt_quote_post_payment.paid, "melt quote should be paid" - assert melt_quote_post_payment.state == MeltQuoteState.paid + assert melt_quote_post_payment.paid @pytest.mark.asyncio @@ -92,25 +91,25 @@ async def test_melt_external(wallet1: Wallet, ledger: Ledger): melt_quote_pre_payment = await ledger.get_melt_quote(melt_quote.quote) assert not melt_quote_pre_payment.paid, "melt quote should not be paid" - assert melt_quote_pre_payment.state == MeltQuoteState.unpaid + assert melt_quote_pre_payment.unpaid assert not melt_quote.paid, "melt quote should not be paid" await ledger.melt(proofs=send_proofs, quote=melt_quote.quote) melt_quote_post_payment = await ledger.get_melt_quote(melt_quote.quote) assert melt_quote_post_payment.paid, "melt quote should be paid" - assert melt_quote_post_payment.state == MeltQuoteState.paid + assert melt_quote_post_payment.paid @pytest.mark.asyncio @pytest.mark.skipif(is_regtest, reason="only works with FakeWallet") async def test_mint_internal(wallet1: Wallet, ledger: Ledger): invoice = await wallet1.request_mint(128) - await pay_if_regtest(invoice.bolt11) + await ledger.get_mint_quote(invoice.id) mint_quote = await ledger.get_mint_quote(invoice.id) assert mint_quote.paid, "mint quote should be paid" - assert mint_quote.state == MintQuoteState.paid + assert mint_quote.paid output_amounts = [128] secrets, rs, derivation_paths = await wallet1.generate_n_secrets( @@ -125,8 +124,8 @@ async def test_mint_internal(wallet1: Wallet, ledger: Ledger): ) mint_quote_after_payment = await ledger.get_mint_quote(invoice.id) - assert mint_quote_after_payment.paid, "mint quote should be paid" - assert mint_quote_after_payment.state == MintQuoteState.issued + assert mint_quote_after_payment.issued, "mint quote should be issued" + assert mint_quote_after_payment.issued @pytest.mark.asyncio @@ -134,11 +133,11 @@ async def test_mint_internal(wallet1: Wallet, ledger: Ledger): async def test_mint_external(wallet1: Wallet, ledger: Ledger): quote = await ledger.mint_quote(PostMintQuoteRequest(amount=128, unit="sat")) assert not quote.paid, "mint quote should not be paid" - assert quote.state == MintQuoteState.unpaid + assert quote.unpaid mint_quote = await ledger.get_mint_quote(quote.quote) assert not mint_quote.paid, "mint quote already paid" - assert mint_quote.state == MintQuoteState.unpaid + assert mint_quote.unpaid await assert_err( wallet1.mint(128, id=quote.quote), @@ -149,7 +148,7 @@ async def test_mint_external(wallet1: Wallet, ledger: Ledger): mint_quote = await ledger.get_mint_quote(quote.quote) assert mint_quote.paid, "mint quote should be paid" - assert mint_quote.state == MintQuoteState.paid + assert mint_quote.paid output_amounts = [128] secrets, rs, derivation_paths = await wallet1.generate_n_secrets( @@ -159,8 +158,7 @@ async def test_mint_external(wallet1: Wallet, ledger: Ledger): await ledger.mint(outputs=outputs, quote_id=quote.quote) mint_quote_after_payment = await ledger.get_mint_quote(quote.quote) - assert mint_quote_after_payment.paid, "mint quote should be paid" - assert mint_quote_after_payment.state == MintQuoteState.issued + assert mint_quote_after_payment.issued, "mint quote should be issued" @pytest.mark.asyncio diff --git a/tests/test_mint_regtest.py b/tests/test_mint_regtest.py index 726dfcd5..a37332ab 100644 --- a/tests/test_mint_regtest.py +++ b/tests/test_mint_regtest.py @@ -1,17 +1,23 @@ import asyncio +import bolt11 import pytest import pytest_asyncio -from cashu.core.base import ProofSpentState +from cashu.core.base import Amount, MeltQuote, MeltQuoteState, Method, Unit +from cashu.core.models import PostMeltQuoteRequest from cashu.mint.ledger import Ledger from cashu.wallet.wallet import Wallet from tests.conftest import SERVER_ENDPOINT from tests.helpers import ( SLEEP_TIME, + cancel_invoice, get_hold_invoice, + get_real_invoice, + get_real_invoice_cln, is_fake, pay_if_regtest, + pay_real_invoice, settle_invoice, ) @@ -27,6 +33,243 @@ async def wallet(): yield wallet +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_lightning_create_invoice(ledger: Ledger): + invoice = await ledger.backends[Method.bolt11][Unit.sat].create_invoice( + Amount(Unit.sat, 1000) + ) + assert invoice.ok + assert invoice.payment_request + assert invoice.checking_id + + # TEST 2: check the invoice status + status = await ledger.backends[Method.bolt11][Unit.sat].get_invoice_status( + invoice.checking_id + ) + assert status.pending + + # settle the invoice + await pay_if_regtest(invoice.payment_request) + + # TEST 3: check the invoice status + status = await ledger.backends[Method.bolt11][Unit.sat].get_invoice_status( + invoice.checking_id + ) + assert status.settled + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_lightning_get_payment_quote(ledger: Ledger): + invoice_dict = get_real_invoice(64) + request = invoice_dict["payment_request"] + payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote( + PostMeltQuoteRequest(request=request, unit=Unit.sat.name) + ) + assert payment_quote.amount == Amount(Unit.sat, 64) + assert payment_quote.checking_id + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_lightning_pay_invoice(ledger: Ledger): + invoice_dict = get_real_invoice(64) + request = invoice_dict["payment_request"] + quote = MeltQuote( + quote="test", + method=Method.bolt11.name, + unit=Unit.sat.name, + state=MeltQuoteState.unpaid, + request=request, + checking_id="test", + amount=64, + fee_reserve=0, + ) + payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice(quote, 1000) + assert payment.settled + assert payment.preimage + assert payment.checking_id + assert not payment.error_message + + # TEST 2: check the payment status + status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status( + payment.checking_id + ) + assert status.settled + assert status.preimage + assert not status.error_message + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_lightning_pay_invoice_failure(ledger: Ledger): + # create an invoice with the external CLN node and pay it with the external LND – so that our mint backend can't pay it + request = get_real_invoice_cln(64) + # pay the invoice so that the attempt later fails + pay_real_invoice(request) + + # we call get_payment_quote to get a checking_id that we will use to check for the failed pending state later with get_payment_status + payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote( + PostMeltQuoteRequest(request=request, unit=Unit.sat.name) + ) + checking_id = payment_quote.checking_id + + # TEST 1: check the payment status + status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status( + checking_id + ) + assert status.unknown + + # TEST 2: pay the invoice + quote = MeltQuote( + quote="test", + method=Method.bolt11.name, + unit=Unit.sat.name, + state=MeltQuoteState.unpaid, + request=request, + checking_id="test", + amount=64, + fee_reserve=0, + ) + payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice(quote, 1000) + + assert payment.failed + assert not payment.preimage + assert payment.error_message + assert not payment.checking_id + + # TEST 3: check the payment status + status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status( + checking_id + ) + + assert status.failed or status.unknown + assert not status.preimage + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_lightning_pay_invoice_pending_success(ledger: Ledger): + # create a hold invoice + preimage, invoice_dict = get_hold_invoice(64) + request = str(invoice_dict["payment_request"]) + + # we call get_payment_quote to get a checking_id that we will use to check for the failed pending state later with get_payment_status + payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote( + PostMeltQuoteRequest(request=request, unit=Unit.sat.name) + ) + checking_id = payment_quote.checking_id + + # pay the invoice + quote = MeltQuote( + quote="test", + method=Method.bolt11.name, + unit=Unit.sat.name, + state=MeltQuoteState.unpaid, + request=request, + checking_id=checking_id, + amount=64, + fee_reserve=0, + ) + + async def pay(): + payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice( + quote, 1000 + ) + return payment + + task = asyncio.create_task(pay()) + await asyncio.sleep(SLEEP_TIME) + + # check the payment status + status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status( + quote.checking_id + ) + assert status.pending + + # settle the invoice + settle_invoice(preimage=preimage) + await asyncio.sleep(SLEEP_TIME) + + # collect the payment + payment = await task + assert payment.settled + assert payment.preimage + assert payment.checking_id + assert not payment.error_message + + # check the payment status + status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status( + quote.checking_id + ) + assert status.settled + assert status.preimage + assert not status.error_message + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_lightning_pay_invoice_pending_failure(ledger: Ledger): + # create a hold invoice + preimage, invoice_dict = get_hold_invoice(64) + request = str(invoice_dict["payment_request"]) + payment_hash = bolt11.decode(request).payment_hash + + # we call get_payment_quote to get a checking_id that we will use to check for the failed pending state later with get_payment_status + payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote( + PostMeltQuoteRequest(request=request, unit=Unit.sat.name) + ) + checking_id = payment_quote.checking_id + + # pay the invoice + quote = MeltQuote( + quote="test", + method=Method.bolt11.name, + unit=Unit.sat.name, + state=MeltQuoteState.unpaid, + request=request, + checking_id=checking_id, + amount=64, + fee_reserve=0, + ) + + async def pay(): + payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice( + quote, 1000 + ) + return payment + + task = asyncio.create_task(pay()) + await asyncio.sleep(SLEEP_TIME) + + # check the payment status + status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status( + quote.checking_id + ) + assert status.pending + + # cancel the invoice + cancel_invoice(payment_hash) + await asyncio.sleep(SLEEP_TIME) + + # collect the payment + payment = await task + assert payment.failed + assert not payment.preimage + # assert payment.error_message + + # check the payment status + status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status( + quote.checking_id + ) + assert ( + status.failed or status.unknown + ) # some backends send unknown instead of failed if they can't find the payment + assert not status.preimage + # assert status.error_message + + @pytest.mark.asyncio @pytest.mark.skipif(is_fake, reason="only regtest") async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): @@ -63,7 +306,7 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): # expect that proofs are still pending states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == ProofSpentState.pending for s in states]) + assert all([s.pending for s in states]) # only now settle the invoice settle_invoice(preimage=preimage) @@ -71,7 +314,7 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): # expect that proofs are now spent states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == ProofSpentState.spent for s in states]) + assert all([s.spent for s in states]) # expect that no melt quote is pending melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( diff --git a/tests/test_wallet_api.py b/tests/test_wallet_api.py index 14602f2e..2e391743 100644 --- a/tests/test_wallet_api.py +++ b/tests/test_wallet_api.py @@ -4,7 +4,7 @@ import pytest_asyncio from fastapi.testclient import TestClient -from cashu.lightning.base import InvoiceResponse, PaymentStatus +from cashu.lightning.base import InvoiceResponse, PaymentResult, PaymentStatus from cashu.wallet.api.app import app from cashu.wallet.wallet import Wallet from tests.conftest import SERVER_ENDPOINT @@ -29,8 +29,8 @@ async def test_invoice(wallet: Wallet): response = client.post("/lightning/create_invoice?amount=100") assert response.status_code == 200 invoice_response = InvoiceResponse.parse_obj(response.json()) - state = PaymentStatus(paid=False) - while not state.paid: + state = PaymentStatus(result=PaymentResult.PENDING) + while state.pending: print("checking invoice state") response2 = client.get( f"/lightning/invoice_state?payment_hash={invoice_response.checking_id}" @@ -171,8 +171,8 @@ async def test_flow(wallet: Wallet): initial_balance = response.json()["balance"] response = client.post("/lightning/create_invoice?amount=100") invoice_response = InvoiceResponse.parse_obj(response.json()) - state = PaymentStatus(paid=False) - while not state.paid: + state = PaymentStatus(result=PaymentResult.PENDING) + while state.pending: print("checking invoice state") response2 = client.get( f"/lightning/invoice_state?payment_hash={invoice_response.checking_id}" diff --git a/tests/test_wallet_lightning.py b/tests/test_wallet_lightning.py index 5dd567f8..b9b3cc6e 100644 --- a/tests/test_wallet_lightning.py +++ b/tests/test_wallet_lightning.py @@ -83,7 +83,7 @@ async def test_check_invoice_internal(wallet: LightningWallet): assert invoice.payment_request assert invoice.checking_id status = await wallet.get_invoice_status(invoice.checking_id) - assert status.paid + assert status.settled @pytest.mark.asyncio @@ -94,10 +94,10 @@ async def test_check_invoice_external(wallet: LightningWallet): assert invoice.payment_request assert invoice.checking_id status = await wallet.get_invoice_status(invoice.checking_id) - assert not status.paid + assert not status.settled await pay_if_regtest(invoice.payment_request) status = await wallet.get_invoice_status(invoice.checking_id) - assert status.paid + assert status.settled @pytest.mark.asyncio @@ -115,12 +115,12 @@ async def test_pay_invoice_internal(wallet: LightningWallet): assert invoice2.payment_request status = await wallet.pay_invoice(invoice2.payment_request) - assert status.ok + assert status.settled # check payment assert invoice2.checking_id status = await wallet.get_payment_status(invoice2.checking_id) - assert status.paid + assert status.settled @pytest.mark.asyncio @@ -132,16 +132,16 @@ async def test_pay_invoice_external(wallet: LightningWallet): assert invoice.checking_id await pay_if_regtest(invoice.payment_request) status = await wallet.get_invoice_status(invoice.checking_id) - assert status.paid + assert status.settled assert wallet.available_balance >= 64 # pay invoice invoice_real = get_real_invoice(16) status = await wallet.pay_invoice(invoice_real["payment_request"]) - assert status.ok + assert status.settled # check payment assert status.checking_id status = await wallet.get_payment_status(status.checking_id) - assert status.paid + assert status.settled diff --git a/tests/test_wallet_p2pk.py b/tests/test_wallet_p2pk.py index d3dcde53..d86e036f 100644 --- a/tests/test_wallet_p2pk.py +++ b/tests/test_wallet_p2pk.py @@ -7,7 +7,7 @@ import pytest import pytest_asyncio -from cashu.core.base import Proof, ProofSpentState +from cashu.core.base import Proof from cashu.core.crypto.secp import PrivateKey, PublicKey from cashu.core.migrations import migrate_databases from cashu.core.p2pk import SigFlags @@ -80,7 +80,7 @@ async def test_p2pk(wallet1: Wallet, wallet2: Wallet): await wallet2.redeem(send_proofs) proof_states = await wallet2.check_proof_state(send_proofs) - assert all([p.state == ProofSpentState.spent for p in proof_states.states]) + assert all([p.spent for p in proof_states.states]) if not is_deprecated_api_only: for state in proof_states.states: diff --git a/tests/test_wallet_regtest.py b/tests/test_wallet_regtest.py index 526fa119..a9a792d2 100644 --- a/tests/test_wallet_regtest.py +++ b/tests/test_wallet_regtest.py @@ -4,7 +4,6 @@ import pytest import pytest_asyncio -from cashu.core.base import ProofSpentState from cashu.mint.ledger import Ledger from cashu.wallet.wallet import Wallet from tests.conftest import SERVER_ENDPOINT @@ -57,14 +56,14 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): await asyncio.sleep(SLEEP_TIME) states = await wallet.check_proof_state(send_proofs) - assert all([s.state == ProofSpentState.pending for s in states.states]) + assert all([s.pending for s in states.states]) settle_invoice(preimage=preimage) await asyncio.sleep(SLEEP_TIME) states = await wallet.check_proof_state(send_proofs) - assert all([s.state == ProofSpentState.spent for s in states.states]) + assert all([s.spent for s in states.states]) @pytest.mark.asyncio @@ -97,11 +96,11 @@ async def test_regtest_failed_quote(wallet: Wallet, ledger: Ledger): await asyncio.sleep(SLEEP_TIME) states = await wallet.check_proof_state(send_proofs) - assert all([s.state == ProofSpentState.pending for s in states.states]) + assert all([s.pending for s in states.states]) cancel_invoice(preimage_hash=preimage_hash) await asyncio.sleep(SLEEP_TIME) states = await wallet.check_proof_state(send_proofs) - assert all([s.state == ProofSpentState.unspent for s in states.states]) + assert all([s.unspent for s in states.states]) diff --git a/tests/test_wallet_regtest_mpp.py b/tests/test_wallet_regtest_mpp.py index a6040d6a..8e94f6e3 100644 --- a/tests/test_wallet_regtest_mpp.py +++ b/tests/test_wallet_regtest_mpp.py @@ -59,19 +59,24 @@ async def _mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]): fee_reserve_sat=quote.fee_reserve, quote_id=quote.quote, ) + def mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]): asyncio.run(_mint_pay_mpp(invoice, amount, proofs)) # call pay_mpp twice in parallel to pay the full invoice - t1 = threading.Thread(target=mint_pay_mpp, args=(invoice_payment_request, 32, proofs1)) - t2 = threading.Thread(target=partial_pay_real_invoice, args=(invoice_payment_request, 32, 1)) + t1 = threading.Thread( + target=mint_pay_mpp, args=(invoice_payment_request, 32, proofs1) + ) + t2 = threading.Thread( + target=partial_pay_real_invoice, args=(invoice_payment_request, 32, 1) + ) t1.start() t2.start() t1.join() t2.join() - assert wallet.balance <= 256 - 32 + assert wallet.balance == 64 @pytest.mark.asyncio @@ -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 if not ledger.backends[Method["bolt11"]][wallet.unit].supports_mpp: pytest.skip("backend does not support mpp") - + # This test cannot be done with CLN because we only have one mint # and CLN hates multiple partial payment requests if isinstance(ledger.backends[Method["bolt11"]][wallet.unit], CLNRestWallet): diff --git a/tests/test_wallet_subscription.py b/tests/test_wallet_subscription.py index ac4711fa..dd07cbb6 100644 --- a/tests/test_wallet_subscription.py +++ b/tests/test_wallet_subscription.py @@ -3,7 +3,7 @@ import pytest import pytest_asyncio -from cashu.core.base import Method, MintQuoteState, ProofSpentState, ProofState +from cashu.core.base import Method, MintQuoteState, ProofState from cashu.core.json_rpc.base import JSONRPCNotficationParams from cashu.core.nuts import WEBSOCKETS_NUT from cashu.core.settings import settings @@ -54,13 +54,10 @@ def callback(msg: JSONRPCNotficationParams): assert triggered assert len(msg_stack) == 3 - assert msg_stack[0].payload["paid"] is False assert msg_stack[0].payload["state"] == MintQuoteState.unpaid.value - assert msg_stack[1].payload["paid"] is True assert msg_stack[1].payload["state"] == MintQuoteState.paid.value - assert msg_stack[2].payload["paid"] is True assert msg_stack[2].payload["state"] == MintQuoteState.issued.value @@ -100,16 +97,16 @@ def callback(msg: JSONRPCNotficationParams): pending_stack = msg_stack[:n_subscriptions] for msg in pending_stack: proof_state = ProofState.parse_obj(msg.payload) - assert proof_state.state == ProofSpentState.unspent + assert proof_state.unspent # the second one is the PENDING state spent_stack = msg_stack[n_subscriptions : n_subscriptions * 2] for msg in spent_stack: proof_state = ProofState.parse_obj(msg.payload) - assert proof_state.state == ProofSpentState.pending + assert proof_state.pending # the third one is the SPENT state spent_stack = msg_stack[n_subscriptions * 2 :] for msg in spent_stack: proof_state = ProofState.parse_obj(msg.payload) - assert proof_state.state == ProofSpentState.spent + assert proof_state.spent