diff --git a/config.json b/config.json index 14ff1743..f8c2da5d 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,7 @@ { - "name": "Cashu", - "short_description": "Ecash mint and wallet", - "tile": "/cashu/static/image/cashu.png", - "contributors": ["calle", "vlad", "arcbtc"], - "hidden": false + "name": "Cashu", + "short_description": "Ecash mint and wallet", + "tile": "/cashu/static/image/cashu.png", + "contributors": ["calle", "vlad", "arcbtc"], + "hidden": false } diff --git a/lib/cashu/core/base.py b/lib/cashu/core/base.py index 4945b812..4b38c78e 100644 --- a/lib/cashu/core/base.py +++ b/lib/cashu/core/base.py @@ -1,5 +1,6 @@ import base64 import json +import time from sqlite3 import Row from typing import Any, Dict, List, Optional, Union @@ -19,8 +20,29 @@ class SecretKind: P2PK = "P2PK" +class SigFlags: + SIG_INPUTS = ( + "SIG_INPUTS" # require signatures only on the inputs (default signature flag) + ) + SIG_ALL = "SIG_ALL" # require signatures on inputs and outputs + + class Tags(BaseModel): - __root__: List[List[str]] + """ + Tags are used to encode additional information in the Secret of a Proof. + """ + + __root__: List[List[str]] = [] + + def __init__(self, tags: Optional[List[List[str]]] = None, **kwargs): + super().__init__(**kwargs) + self.__root__ = tags or [] + + def __setitem__(self, key: str, value: str) -> None: + self.__root__.append([key, value]) + + def __getitem__(self, key: str) -> Union[str, None]: + return self.get_tag(key) def get_tag(self, tag_name: str) -> Union[str, None]: for tag in self.__root__: @@ -28,6 +50,13 @@ def get_tag(self, tag_name: str) -> Union[str, None]: return tag[1] return None + def get_tag_all(self, tag_name: str) -> List[str]: + all_tags = [] + for tag in self.__root__: + if tag[0] == tag_name: + all_tags.append(tag[1]) + return all_tags + class Secret(BaseModel): """Describes spending condition encoded in the secret field of a Proof.""" @@ -35,7 +64,6 @@ class Secret(BaseModel): kind: str data: str nonce: Union[None, str] = None - timelock: Union[None, int] = None tags: Union[None, Tags] = None def serialize(self) -> str: @@ -43,23 +71,76 @@ def serialize(self) -> str: "data": self.data, "nonce": self.nonce or PrivateKey().serialize()[:32], } - if self.timelock: - data_dict["timelock"] = self.timelock - if self.tags: + if self.tags and self.tags.__root__: + logger.debug(f"Serializing tags: {self.tags.__root__}") data_dict["tags"] = self.tags.__root__ - logger.debug( - json.dumps( - [self.kind, data_dict], - ) - ) return json.dumps( [self.kind, data_dict], ) @classmethod - def deserialize(cls, data: str): - kind, kwargs = json.loads(data) - return cls(kind=kind, **kwargs) + def deserialize(cls, from_proof: str): + kind, kwargs = json.loads(from_proof) + data = kwargs.pop("data") + nonce = kwargs.pop("nonce") + tags_list = kwargs.pop("tags", None) + if tags_list: + tags = Tags(tags=tags_list) + else: + tags = None + logger.debug(f"Deserialized Secret: {kind}, {data}, {nonce}, {tags}") + return cls(kind=kind, data=data, nonce=nonce, tags=tags) + + @property + def locktime(self) -> Union[None, int]: + if self.tags: + locktime = self.tags.get_tag("locktime") + if locktime: + return int(locktime) + return None + + @property + def sigflag(self) -> Union[None, str]: + if self.tags: + sigflag = self.tags.get_tag("sigflag") + if sigflag: + return sigflag + return None + + @property + def n_sigs(self) -> Union[None, int]: + if self.tags: + n_sigs = self.tags.get_tag("n_sigs") + if n_sigs: + return int(n_sigs) + return None + + def get_p2pk_pubkey_from_secret(self) -> List[str]: + """Gets the P2PK pubkey from a Secret depending on the locktime + + Args: + secret (Secret): P2PK Secret in ecash token + + Returns: + str: pubkey to use for P2PK, empty string if anyone can spend (locktime passed) + """ + pubkeys: List[str] = [self.data] # for now we only support one pubkey + # get all additional pubkeys from tags for multisig + if self.tags and self.tags.get_tag("pubkey"): + pubkeys += self.tags.get_tag_all("pubkey") + + now = time.time() + if self.locktime and self.locktime < now: + logger.trace(f"p2pk locktime ran out ({self.locktime}<{now}).") + # check tags if a refund pubkey is present. + # If yes, we demand the signature to be from the refund pubkey + if self.tags: + refund_pubkey = self.tags.get_tag("refund") + if refund_pubkey: + pubkeys = [refund_pubkey] + return pubkeys + return [] + return pubkeys class P2SHScript(BaseModel): @@ -83,7 +164,7 @@ class Proof(BaseModel): amount: int = 0 secret: str = "" # secret or message to be blinded and signed C: str = "" # signature on secret, unblinded by wallet - p2pksig: Optional[str] = None # P2PK signature + p2pksigs: Union[List[str], None] = [] # P2PK signature p2shscript: Union[P2SHScript, None] = None # P2SH spending condition reserved: Union[ None, bool @@ -121,6 +202,7 @@ class BlindedMessage(BaseModel): amount: int B_: str # Hex-encoded blinded message + p2pksigs: Union[List[str], None] = None # signature for p2pk with SIG_ALL class BlindedSignature(BaseModel): @@ -253,6 +335,10 @@ class CheckSpendableRequest(BaseModel): class CheckSpendableResponse(BaseModel): spendable: List[bool] + pending: Optional[ + List[bool] + ] = None # TODO: Uncomment when all mints are updated to 0.12.3 and support /check + # with pending tokens (kept for backwards compatibility of new wallets with old mints) class CheckFeesRequest(BaseModel): diff --git a/lib/cashu/core/settings.py b/lib/cashu/core/settings.py index 80583b3a..4fc4550f 100644 --- a/lib/cashu/core/settings.py +++ b/lib/cashu/core/settings.py @@ -8,7 +8,7 @@ env = Env() -VERSION = "0.12.2" +VERSION = "0.12.3" def find_env_file(): @@ -98,7 +98,7 @@ class WalletSettings(CashuSettings): ] ) - timelock_delta_seconds: int = Field(default=86400) # 1 day + locktime_delta_seconds: int = Field(default=86400) # 1 day class Settings( diff --git a/lib/cashu/mint/ledger.py b/lib/cashu/mint/ledger.py index fe5d9036..21af789d 100644 --- a/lib/cashu/mint/ledger.py +++ b/lib/cashu/mint/ledger.py @@ -2,7 +2,7 @@ import json import math import time -from typing import Dict, List, Literal, Optional, Set, Union +from typing import Dict, List, Literal, Optional, Set, Tuple, Union from loguru import logger @@ -16,6 +16,7 @@ Proof, Secret, SecretKind, + SigFlags, ) from ..core.crypto import b_dhke from ..core.crypto.keys import derive_pubkey, random_hash @@ -186,6 +187,15 @@ def _check_spendable(self, proof: Proof): """Checks whether the proof was already spent.""" return not proof.secret in self.proofs_used + async def _check_pending(self, proofs: List[Proof]): + """Checks whether the proof is still pending.""" + proofs_pending = await self.crud.get_proofs_pending(db=self.db) + pending_secrets = [pp.secret for pp in proofs_pending] + pending_states = [ + True if p.secret in pending_secrets else False for p in proofs + ] + return pending_states + def _verify_secret_criteria(self, proof: Proof) -> Literal[True]: """Verifies that a secret is present and is not too long (DOS prevention).""" if proof.secret is None or proof.secret == "": @@ -214,26 +224,28 @@ def _verify_proof_bdhke(self, proof: Proof): C = PublicKey(bytes.fromhex(proof.C), raw=True) return b_dhke.verify(private_key_amount, C, proof.secret) - def _verify_spending_conditions(self, proof: Proof) -> bool: + def _verify_input_spending_conditions(self, proof: Proof) -> bool: """ Verify spending conditions: Condition: P2SH - Witnesses proof.p2shscript - Condition: P2PK - Witness: proof.p2pksig + Condition: P2PK - Witness: proof.p2pksigs """ # P2SH try: secret = Secret.deserialize(proof.secret) + logger.trace(f"proof.secret: {proof.secret}") + logger.trace(f"secret: {secret}") except Exception as e: # secret is not a spending condition so we treat is a normal secret return True if secret.kind == SecretKind.P2SH: - # check if timelock is in the past + # check if locktime is in the past now = time.time() - if secret.timelock and secret.timelock < now: - logger.trace(f"p2sh timelock ran out ({secret.timelock}<{now}).") + if secret.locktime and secret.locktime < now: + logger.trace(f"p2sh locktime ran out ({secret.locktime}<{now}).") return True - logger.trace(f"p2sh timelock still active ({secret.timelock}>{now}).") + logger.trace(f"p2sh locktime still active ({secret.locktime}>{now}).") if ( proof.p2shscript is None @@ -257,58 +269,165 @@ def _verify_spending_conditions(self, proof: Proof) -> bool: # P2PK if secret.kind == SecretKind.P2PK: - # check if timelock is in the past - now = time.time() - if secret.timelock and secret.timelock < now: - logger.trace(f"p2pk timelock ran out ({secret.timelock}<{now}).") - # check tags if a refund pubkey is present. - # If yes, we demand the signature to be from the refund pubkey - if secret.tags and secret.tags.get_tag("refund"): - signature_pubkey = secret.tags.get_tag("refund") - else: - # if no refund pubkey is present and the timelock has expired - # the token can be spent by anyone - return True - else: - # the timelock is still active, therefore we demand the signature - # to be from the pubkey in the data field - signature_pubkey = secret.data - logger.trace(f"p2pk timelock still active ({secret.timelock}>{now}).") + # check if locktime is in the past + pubkeys = secret.get_p2pk_pubkey_from_secret() + assert len(set(pubkeys)) == len(pubkeys), f"pubkeys must be unique." + logger.trace(f"pubkeys: {pubkeys}") + # we will get an empty list if the locktime has passed and no refund pubkey is present + if not pubkeys: + return True # now we check the signature - if not proof.p2pksig: + if not proof.p2pksigs: # no signature present although secret indicates one - raise Exception("no p2pk signature in proof.") + logger.error(f"no p2pk signatures in proof: {proof.p2pksigs}") + raise Exception("no p2pk signatures in proof.") + + # we make sure that there are no duplicate signatures + if len(set(proof.p2pksigs)) != len(proof.p2pksigs): + raise Exception("p2pk signatures must be unique.") # we parse the secret as a P2PK commitment # assert len(proof.secret.split(":")) == 5, "p2pk secret format invalid." - # check signature proof.p2pksig against pubkey + # INPUTS: check signatures proof.p2pksigs against pubkey # we expect the signature to be on the pubkey (=message) itself - assert signature_pubkey, "no signature pubkey present." - assert verify_p2pk_signature( - message=secret.serialize().encode("utf-8"), - pubkey=PublicKey(bytes.fromhex(signature_pubkey), raw=True), - signature=bytes.fromhex(proof.p2pksig), - ), "p2pk signature invalid." - logger.trace(proof.p2pksig) - logger.trace("p2pk signature valid.") + n_sigs_required = secret.n_sigs or 1 + assert n_sigs_required > 0, "n_sigs must be positive." + + # check if enough signatures are present + assert ( + len(proof.p2pksigs) >= n_sigs_required + ), f"not enough signatures provided: {len(proof.p2pksigs)} < {n_sigs_required}." + + n_valid_sigs_per_output = 0 + # loop over all signatures in output + for input_sig in proof.p2pksigs: + for pubkey in pubkeys: + logger.trace(f"verifying signature {input_sig} by pubkey {pubkey}.") + logger.trace(f"Message: {secret.serialize().encode('utf-8')}") + if verify_p2pk_signature( + message=secret.serialize().encode("utf-8"), + pubkey=PublicKey(bytes.fromhex(pubkey), raw=True), + signature=bytes.fromhex(input_sig), + ): + n_valid_sigs_per_output += 1 + logger.trace( + f"p2pk signature on input is valid: {input_sig} on {pubkey}." + ) + continue + else: + logger.trace( + f"p2pk signature on input is invalid: {input_sig} on {pubkey}." + ) + # check if we have enough valid signatures + assert n_valid_sigs_per_output, "no valid signature provided for input." + assert ( + n_valid_sigs_per_output >= n_sigs_required + ), f"signature threshold not met. {n_valid_sigs_per_output} < {n_sigs_required}." + logger.trace( + f"{n_valid_sigs_per_output} of {n_sigs_required} valid signatures found." + ) + + logger.trace(proof.p2pksigs) + logger.trace("p2pk signature on inputs is valid.") return True # no spending contition return True - def _verify_outputs( - self, total: int, amount: int, outputs: List[BlindedMessage] + def _verify_output_spending_conditions( + self, proofs: List[Proof], outputs: List[BlindedMessage] ) -> bool: - """Verifies the expected split was correctly computed""" - frst_amt, scnd_amt = total - amount, amount # we have two amounts to split to - frst_outputs = amount_split(frst_amt) - scnd_outputs = amount_split(scnd_amt) - expected = frst_outputs + scnd_outputs - given = [o.amount for o in outputs] - return given == expected + """ + Verify spending conditions: + Condition: P2PK - Witness: output.p2pksigs + + """ + # P2SH + pubkeys_per_proof = [] + n_sigs = [] + for proof in proofs: + try: + secret = Secret.deserialize(proof.secret) + # get all p2pk pubkeys from secrets + pubkeys_per_proof.append(secret.get_p2pk_pubkey_from_secret()) + # get signature threshold from secrets + n_sigs.append(secret.n_sigs) + except Exception as e: + # secret is not a spending condition so we treat is a normal secret + return True + # for all proofs all pubkeys must be the same + assert ( + len(set([tuple(pubs_output) for pubs_output in pubkeys_per_proof])) == 1 + ), "pubkeys in all proofs must match." + pubkeys = pubkeys_per_proof[0] + if not pubkeys: + # no pubkeys present + return True + + logger.trace(f"pubkeys: {pubkeys}") + # TODO: add limit for maximum number of pubkeys + + # for all proofs all n_sigs must be the same + assert len(set(n_sigs)) == 1, "n_sigs in all proofs must match." + n_sigs_required = n_sigs[0] or 1 + + # first we check if all secrets are P2PK + if not all( + [Secret.deserialize(p.secret).kind == SecretKind.P2PK for p in proofs] + ): + # not all secrets are P2PK + return True + + # now we check if any of the secrets has sigflag==SIG_ALL + if not any( + [Secret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL for p in proofs] + ): + # no secret has sigflag==SIG_ALL + return True + + # loop over all outputs and check if the signatures are valid for pubkeys with a threshold of n_sig + for output in outputs: + # we expect the signature to be on the pubkey (=message) itself + assert output.p2pksigs, "no signatures in output." + # TODO: add limit for maximum number of signatures + + # we check whether any signature is duplicate + assert len(set(output.p2pksigs)) == len( + output.p2pksigs + ), "duplicate signatures in output." + + n_valid_sigs_per_output = 0 + # loop over all signatures in output + for output_sig in output.p2pksigs: + for pubkey in pubkeys: + if verify_p2pk_signature( + message=output.B_.encode("utf-8"), + pubkey=PublicKey(bytes.fromhex(pubkey), raw=True), + signature=bytes.fromhex(output_sig), + ): + n_valid_sigs_per_output += 1 + assert n_valid_sigs_per_output, "no valid signature provided for output." + assert ( + n_valid_sigs_per_output >= n_sigs_required + ), f"signature threshold not met. {n_valid_sigs_per_output} < {n_sigs_required}." + logger.trace( + f"{n_valid_sigs_per_output} of {n_sigs_required} valid signatures found." + ) + logger.trace(output.p2pksigs) + logger.trace("p2pk signatures on output is valid.") + + return True + + def _verify_input_output_amounts( + self, inputs: List[Proof], outputs: List[BlindedMessage] + ) -> bool: + """Verifies that inputs have at least the same amount as outputs""" + input_amount = sum([p.amount for p in inputs]) + output_amount = sum([o.amount for o in outputs]) + return input_amount >= output_amount def _verify_no_duplicate_proofs(self, proofs: List[Proof]) -> bool: secrets = [p.secret for p in proofs] @@ -556,11 +675,14 @@ async def _validate_proofs_pending( if p.secret == pp.secret: raise Exception("proofs are pending.") - async def _verify_proofs(self, proofs: List[Proof]): - """Checks a series of criteria for the verification of proofs. + async def _verify_proofs_and_outputs( + self, proofs: List[Proof], outputs: Optional[List[BlindedMessage]] = None + ): + """Checks all proofs and outputs for validity. Args: proofs (List[Proof]): List of proofs to check. + outputs (Optional[List[BlindedMessage]], optional): List of outputs to check. Must be provided for /split but not for /melt. Defaults to None. Raises: Exception: Scripts did not validate. @@ -568,19 +690,35 @@ async def _verify_proofs(self, proofs: List[Proof]): Exception: Duplicate proofs provided. Exception: BDHKE verification failed. """ - # Verify scripts - if not all([self._verify_spending_conditions(p) for p in proofs]): - raise Exception("script validation failed.") + # Verify inputs + # Verify secret criteria if not all([self._verify_secret_criteria(p) for p in proofs]): raise Exception("secrets do not match criteria.") # verify that only unique proofs were used if not self._verify_no_duplicate_proofs(proofs): raise Exception("duplicate proofs.") - # Verify proofs + # Verify input spending conditions + if not all([self._verify_input_spending_conditions(p) for p in proofs]): + raise Exception("validation of input spending conditions failed.") + # Verify ecash signatures if not all([self._verify_proof_bdhke(p) for p in proofs]): raise Exception("could not verify proofs.") + if not outputs: + return + + # Verify outputs + + # verify that only unique outputs were used + if not self._verify_no_duplicate_outputs(outputs): + raise Exception("duplicate promises.") + if not self._verify_input_output_amounts(proofs, outputs): + raise Exception("input amounts less than output.") + # Verify output spending conditions + if outputs and not self._verify_output_spending_conditions(proofs, outputs): + raise Exception("validation of output spending conditions failed.") + async def _generate_change_promises( self, total_provided: int, @@ -758,7 +896,7 @@ async def melt( await self._set_proofs_pending(proofs) try: - await self._verify_proofs(proofs) + await self._verify_proofs_and_outputs(proofs) logger.trace("verified proofs") total_provided = sum_proofs(proofs) @@ -815,12 +953,14 @@ async def melt( return status, preimage, return_promises - async def check_spendable(self, proofs: List[Proof]): - """Checks if provided proofs are valid and have not been spent yet. - Used by wallets to check if their proofs have been redeemed by a receiver. + async def check_proof_state( + self, proofs: List[Proof] + ) -> Tuple[List[bool], List[bool]]: + """Checks if provided proofs are spend or are pending. + Used by wallets to check if their proofs have been redeemed by a receiver or they are still in-flight in a transaction. - Returns a list in the same order as the provided proofs. Wallet must match the list - to the proofs they have provided in order to figure out which proof is still spendable + Returns two lists that are in the same order as the provided proofs. Wallet must match the list + to the proofs they have provided in order to figure out which proof is spendable or pending and which isn't. Args: @@ -828,8 +968,11 @@ async def check_spendable(self, proofs: List[Proof]): Returns: List[bool]: List of which proof is still spendable (True if still spendable, else False) + List[bool]: List of which proof are pending (True if pending, else False) """ - return [self._check_spendable(p) for p in proofs] + spendable = [self._check_spendable(p) for p in proofs] + pending = await self._check_pending(proofs) + return spendable, pending async def check_fees(self, pr: str): """Returns the fee reserve (in sat) that a wallet must add to its proofs @@ -895,16 +1038,13 @@ async def split( if amount > total: raise Exception("split amount is higher than the total sum.") - logger.trace("verifying proofs: _verify_proofs") - await self._verify_proofs(proofs) - logger.trace(f"verified proofs") - # verify that only unique outputs were used - if not self._verify_no_duplicate_outputs(outputs): - raise Exception("duplicate promises.") - # verify that outputs have the correct amount - if not self._verify_outputs(total, amount, outputs): - raise Exception("split of promises is not as expected.") - logger.trace(f"verified outputs") + logger.trace("verifying proofs: _verify_proofs_and_outputs") + await self._verify_proofs_and_outputs(proofs, outputs) + logger.trace(f"verified proofs and outputs") + # Mark proofs as used and prepare new promises + logger.trace(f"invalidating proofs") + await self._invalidate_proofs(proofs) + logger.trace(f"invalidated proofs") except Exception as e: logger.trace(f"split failed: {e}") raise e @@ -912,11 +1052,6 @@ async def split( # delete proofs from pending list await self._unset_proofs_pending(proofs) - # Mark proofs as used and prepare new promises - logger.trace(f"invalidating proofs") - await self._invalidate_proofs(proofs) - logger.trace(f"invalidated proofs") - # split outputs according to amount outs_fst = amount_split(total - amount) B_fst = [od for od in outputs[: len(outs_fst)]] diff --git a/lib/cashu/mint/router.py b/lib/cashu/mint/router.py index 728000d8..d8bde402 100644 --- a/lib/cashu/mint/router.py +++ b/lib/cashu/mint/router.py @@ -173,17 +173,18 @@ async def melt(payload: PostMeltRequest) -> Union[CashuError, GetMeltResponse]: @router.post( "/check", - name="Check spendable", - summary="Check whether a proof has already been spent", + name="Check proof state", + summary="Check whether a proof is spent already or is pending in a transaction", ) async def check_spendable( payload: CheckSpendableRequest, ) -> CheckSpendableResponse: """Check whether a secret has been spent already or not.""" logger.trace(f"> POST /check: {payload}") - spendableList = await ledger.check_spendable(payload.proofs) - logger.trace(f"< POST /check: {spendableList}") - return CheckSpendableResponse(spendable=spendableList) + spendableList, pendingList = await ledger.check_proof_state(payload.proofs) + logger.trace(f"< POST /check : {spendableList}") + logger.trace(f"< POST /check : {pendingList}") + return CheckSpendableResponse(spendable=spendableList, pending=pendingList) @router.post( diff --git a/manifest.json b/manifest.json index b3f27593..db512619 100644 --- a/manifest.json +++ b/manifest.json @@ -7,4 +7,4 @@ "repository": "cashu" } ] -} +} \ No newline at end of file diff --git a/views_api.py b/views_api.py index e934b7c3..83d79fb7 100644 --- a/views_api.py +++ b/views_api.py @@ -1,5 +1,5 @@ -import math import asyncio +import math from http import HTTPStatus from typing import Dict, Union @@ -137,9 +137,7 @@ async def keys(cashu_id: str) -> dict[int, str]: @cashu_ext.get("/api/v1/{cashu_id}/keys/{idBase64Urlsafe}") -async def keyset_keys( - cashu_id: str, idBase64Urlsafe: str -) -> dict[int, str]: +async def keyset_keys(cashu_id: str, idBase64Urlsafe: str) -> dict[int, str]: """ Get the public keys of the mint of a specificy keyset id. The id is encoded in base64_urlsafe and needs to be converted back to @@ -219,6 +217,8 @@ async def mint( """ Requests the minting of tokens belonging to a paid payment request. Call this endpoint after `GET /mint`. + + Note: This endpoint implements the logic in ledger.mint() and ledger._check_lightning_invoice() """ cashu: Union[Cashu, None] = await get_cashu(cashu_id) if cashu is None: @@ -288,9 +288,7 @@ async def mint( @cashu_ext.post("/api/v1/{cashu_id}/melt") -async def melt_coins( - payload: PostMeltRequest, cashu_id: str -) -> GetMeltResponse: +async def melt_coins(payload: PostMeltRequest, cashu_id: str) -> GetMeltResponse: """Invalidates proofs and pays a Lightning invoice.""" cashu: Union[None, Cashu] = await get_cashu(cashu_id) if cashu is None: @@ -311,7 +309,7 @@ async def melt_coins( # set proofs as pending await ledger._set_proofs_pending(proofs) try: - await ledger._verify_proofs(proofs) + await ledger._verify_proofs_and_outputs(proofs) total_provided = sum([p["amount"] for p in proofs]) invoice_obj = bolt11.decode(invoice) @@ -386,14 +384,12 @@ async def check_spendable( raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist." ) - spendableList = await ledger.check_spendable(payload.proofs) - return CheckSpendableResponse(spendable=spendableList) + spendableList, pendingList = await ledger.check_proof_state(payload.proofs) + return CheckSpendableResponse(spendable=spendableList, pending=pendingList) @cashu_ext.post("/api/v1/{cashu_id}/checkfees") -async def check_fees( - payload: CheckFeesRequest, cashu_id: str -) -> CheckFeesResponse: +async def check_fees(payload: CheckFeesRequest, cashu_id: str) -> CheckFeesResponse: """ Responds with the fees necessary to pay a Lightning invoice. Used by wallets for figuring out the fees they need to supply. @@ -415,9 +411,7 @@ async def check_fees( @cashu_ext.post("/api/v1/{cashu_id}/split") -async def split( - payload: PostSplitRequest, cashu_id: str -) -> PostSplitResponse: +async def split(payload: PostSplitRequest, cashu_id: str) -> PostSplitResponse: """ Requetst a set of tokens with amount "total" to be split into two newly minted sets with amount "split" and "total-split".