diff --git a/cashu/core/base.py b/cashu/core/base.py index b724e48..598836a 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -271,11 +271,6 @@ class PostMintRequest(BaseModel): outputs: List[BlindedMessage] -class PostMintResponseLegacy(BaseModel): - # NOTE: Backwards compability for < 0.8.0 where we used a simple list and not a key-value dictionary - __root__: List[BlindedSignature] = [] - - class PostMintResponse(BaseModel): promises: List[BlindedSignature] = [] @@ -305,7 +300,7 @@ class GetMeltResponse(BaseModel): class PostSplitRequest(BaseModel): proofs: List[Proof] - amount: int + amount: Optional[int] = None # deprecated since 0.13.0 outputs: List[BlindedMessage] # signature: Optional[str] = None @@ -323,8 +318,14 @@ class PostSplitRequest(BaseModel): class PostSplitResponse(BaseModel): - fst: List[BlindedSignature] - snd: List[BlindedSignature] + promises: List[BlindedSignature] + + +# deprecated since 0.13.0 +class PostSplitResponse_Deprecated(BaseModel): + fst: List[BlindedSignature] = [] + snd: List[BlindedSignature] = [] + deprecated: str = "The amount field is deprecated since 0.13.0" # ------- API: CHECK ------- diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 1511a3b..67fc5d5 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -444,14 +444,6 @@ class Ledger: return False return True - def _verify_split_amount(self, amount: int) -> None: - """Split amount like output amount can't be negative or too big.""" - try: - self._verify_amount(amount) - except: - # For better error message - raise Exception("invalid split amount: " + str(amount)) - def _verify_amount(self, amount: int) -> int: """Any amount used should be a positive integer not larger than 2^MAX_ORDER.""" valid = ( @@ -463,12 +455,18 @@ class Ledger: return amount def _verify_equation_balanced( - self, proofs: List[Proof], outs: List[BlindedSignature] + self, + proofs: List[Proof], + outs: Union[List[BlindedSignature], List[BlindedMessage]], ) -> None: - """Verify that Σoutputs - Σinputs = 0.""" + """Verify that Σinputs - Σoutputs = 0. + Outputs can be BlindedSignature or BlindedMessage. + """ sum_inputs = sum(self._verify_amount(p.amount) for p in proofs) sum_outputs = sum(self._verify_amount(p.amount) for p in outs) - assert sum_outputs - sum_inputs == 0 + assert ( + sum_outputs - sum_inputs == 0 + ), "inputs do not have same amount as outputs" async def _request_lightning_invoice(self, amount: int): """Generate a Lightning invoice using the funding source backend. @@ -1007,10 +1005,11 @@ class Ledger: async def split( self, + *, proofs: List[Proof], - amount: int, outputs: List[BlindedMessage], keyset: Optional[MintKeyset] = None, + amount: Optional[int] = None, # backwards compatibility < 0.13.0 ): """Consumes proofs and prepares new promises based on the amount split. Used for splitting tokens Before sending or for redeeming tokens for new ones that have been received by another wallet. @@ -1031,15 +1030,15 @@ class Ledger: await self._set_proofs_pending(proofs) - total = sum_proofs(proofs) + total_amount = sum_proofs(proofs) try: logger.trace(f"verifying _verify_split_amount") # verify that amount is kosher - self._verify_split_amount(amount) + self._verify_amount(total_amount) + # verify overspending attempt - if amount > total: - raise Exception("split amount is higher than the total sum.") + self._verify_equation_balanced(proofs, outputs) logger.trace("verifying proofs: _verify_proofs_and_outputs") await self._verify_proofs_and_outputs(proofs, outputs) @@ -1055,20 +1054,32 @@ class Ledger: # delete proofs from pending list await self._unset_proofs_pending(proofs) - # split outputs according to amount - outs_fst = amount_split(total - amount) - B_fst = [od for od in outputs[: len(outs_fst)]] - B_snd = [od for od in outputs[len(outs_fst) :]] + # BEGIN backwards compatibility < 0.13.0 + if amount is not None: + logger.debug( + "Split: Client provided `amount` - backwards compatibility response pre 0.13.0" + ) + # split outputs according to amount + total = sum_proofs(proofs) + if amount > total: + raise Exception("split amount is higher than the total sum.") + outs_fst = amount_split(total - amount) + B_fst = [od for od in outputs[: len(outs_fst)]] + B_snd = [od for od in outputs[len(outs_fst) :]] - # generate promises - prom_fst, prom_snd = await self._generate_promises( - B_fst, keyset - ), await self._generate_promises(B_snd, keyset) + # generate promises + prom_fst = await self._generate_promises(B_fst, keyset) + prom_snd = await self._generate_promises(B_snd, keyset) + promises = prom_fst + prom_snd + # END backwards compatibility < 0.13.0 + else: + promises = await self._generate_promises(outputs, keyset) - # verify amounts in produced proofs - self._verify_equation_balanced(proofs, prom_fst + prom_snd) + # verify amounts in produced promises + self._verify_equation_balanced(proofs, promises) logger.trace(f"split successful") + return promises return prom_fst, prom_snd async def restore( diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 991aa40..181079a 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -22,6 +22,7 @@ from ..core.base import ( PostRestoreResponse, PostSplitRequest, PostSplitResponse, + PostSplitResponse_Deprecated, ) from ..core.errors import CashuError from ..core.settings import settings @@ -208,7 +209,7 @@ async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse: @router.post("/split", name="Split", summary="Split proofs at a specified amount") async def split( payload: PostSplitRequest, -) -> Union[CashuError, PostSplitResponse]: +) -> Union[CashuError, PostSplitResponse, PostSplitResponse_Deprecated]: """ Requetst a set of tokens with amount "total" to be split into two newly minted sets with amount "split" and "total-split". @@ -219,17 +220,36 @@ async def split( logger.trace(f"> POST /split: {payload}") assert payload.outputs, Exception("no outputs provided.") try: - split_return = await ledger.split( - payload.proofs, payload.amount, payload.outputs + promises = await ledger.split( + proofs=payload.proofs, outputs=payload.outputs, amount=payload.amount ) except Exception as exc: return CashuError(code=0, error=str(exc)) - if not split_return: + if not promises: return CashuError(code=0, error="there was an error with the split") - frst_promises, scnd_promises = split_return - resp = PostSplitResponse(fst=frst_promises, snd=scnd_promises) - logger.trace(f"< POST /split: {resp}") - return resp + + if payload.amount: + # BEGIN backwards compatibility < 0.13 + # old clients expect two lists of promises where the second one's amounts + # sum up to `amount`. The first one is the rest. + # The returned value `promises` has the form [keep1, keep2, ..., send1, send2, ...] + # The sum of the sendx is `amount`. We need to split this into two lists and keep the order of the elements. + frst_promises: List[BlindedSignature] = [] + scnd_promises: List[BlindedSignature] = [] + scnd_amount = 0 + for promise in promises[::-1]: # we iterate backwards + if scnd_amount < payload.amount: + scnd_promises.insert(0, promise) # and insert at the beginning + scnd_amount += promise.amount + else: + frst_promises.insert(0, promise) # and insert at the beginning + logger.trace( + f"Split into keep: {len(frst_promises)}: {sum([p.amount for p in frst_promises])} sat and send: {len(scnd_promises)}: {sum([p.amount for p in scnd_promises])} sat" + ) + return PostSplitResponse_Deprecated(fst=frst_promises, snd=scnd_promises) + # END backwards compatibility < 0.13 + else: + return PostSplitResponse(promises=promises) @router.post( diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 0e69e7e..5b48d7c 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -32,9 +32,9 @@ from ..core.base import ( PostMeltRequest, PostMintRequest, PostMintResponse, - PostMintResponseLegacy, PostRestoreResponse, PostSplitRequest, + PostSplitResponse_Deprecated, Proof, Secret, SecretKind, @@ -487,11 +487,7 @@ class LedgerAPI(object): reponse_dict = resp.json() self.raise_on_error(reponse_dict) logger.trace("Lightning invoice checked. POST /mint") - try: - # backwards compatibility: parse promises < 0.8.0 with no "promises" field - promises = PostMintResponseLegacy.parse_obj(reponse_dict).__root__ - except: - promises = PostMintResponse.parse_obj(reponse_dict).promises + promises = PostMintResponse.parse_obj(reponse_dict).promises # bump secret counter in database await bump_secret_derivation( @@ -504,27 +500,16 @@ class LedgerAPI(object): self, proofs: List[Proof], outputs: List[BlindedMessage], - secrets: List[str], - rs: List[PrivateKey], - amount: int, - secret_lock: Optional[Secret] = None, - ) -> Tuple[List[BlindedSignature], List[BlindedSignature]]: - """Consume proofs and create new promises based on amount split. - - If secret_lock is None, random secrets will be generated for the tokens to keep (frst_outputs) - and the promises to send (scnd_outputs). - - If secret_lock is provided, the wallet will create blinded secrets with those to attach a - predefined spending condition to the tokens they want to send.""" + ) -> List[BlindedSignature]: + """Consume proofs and create new promises based on amount split.""" logger.debug("Calling split. POST /split") - split_payload = PostSplitRequest(proofs=proofs, amount=amount, outputs=outputs) + split_payload = PostSplitRequest(proofs=proofs, outputs=outputs) # construct payload def _splitrequest_include_fields(proofs: List[Proof]): """strips away fields from the model that aren't necessary for the /split""" proofs_include = {"id", "amount", "secret", "C", "p2shscript", "p2pksigs"} return { - "amount": ..., "outputs": ..., "proofs": {i: proofs_include for i in range(len(proofs))}, } @@ -537,13 +522,13 @@ class LedgerAPI(object): promises_dict = resp.json() self.raise_on_error(promises_dict) - promises_fst = [BlindedSignature(**p) for p in promises_dict["fst"]] - promises_snd = [BlindedSignature(**p) for p in promises_dict["snd"]] + mint_response = PostMintResponse.parse_obj(promises_dict) + promises = [BlindedSignature(**p.dict()) for p in mint_response.promises] - if len(promises_fst) == 0 and len(promises_snd) == 0: + if len(promises) == 0: raise Exception("received no splits.") - return promises_fst, promises_snd + return promises @async_set_requests async def check_proof_state(self, proofs: List[Proof]): @@ -1065,21 +1050,23 @@ class Wallet(LedgerAPI): amount: int, secret_lock: Optional[Secret] = None, ) -> Tuple[List[Proof], List[Proof]]: - """Split proofs into two sets of proofs at a given amount. + """If secret_lock is None, random secrets will be generated for the tokens to keep (frst_outputs) + and the promises to send (scnd_outputs). + + If secret_lock is provided, the wallet will create blinded secrets with those to attach a + predefined spending condition to the tokens they want to send. Args: - proofs (List[Proof]): Input proofs to split. - amount (int): Amount to split at. - secret_lock (Optional[Secret], optional): Secret to lock outputs to. Defaults to None. - - Raises: - Exception: Raises exception if no proofs have been provided - Exception: Raises exception if no proofs are returned after splitting + proofs (List[Proof]): _description_ + amount (int): _description_ + secret_lock (Optional[Secret], optional): _description_. Defaults to None. Returns: - Tuple[List[Proof], List[Proof]]: Two sets of proofs after splitting. + _type_: _description_ """ - assert len(proofs) > 0, ValueError("no proofs provided.") + assert len(proofs) > 0, "no proofs provided." + assert sum_proofs(proofs) >= amount, "amount too large." + assert amount > 0, "amount must be positive." # potentially add witnesses to unlock provided proofs (if they indicate one) proofs = await self.add_witnesses_to_proofs(proofs) @@ -1125,36 +1112,25 @@ class Wallet(LedgerAPI): outputs = await self.add_witnesses_to_outputs(proofs, outputs) # Call /split API - promises_fst, promises_snd = await super().split( - proofs, outputs, secrets, rs, amount, secret_lock - ) + promises = await super().split(proofs, outputs) # Construct proofs from returned promises (i.e., unblind the signatures) - frst_proofs = self._construct_proofs( - promises_fst, - secrets[: len(promises_fst)], - rs[: len(promises_fst)], - derivation_paths, - ) - scnd_proofs = self._construct_proofs( - promises_snd, - secrets[len(promises_fst) :], - rs[len(promises_fst) :], - derivation_paths, - ) + new_proofs = self._construct_proofs(promises, secrets, rs, derivation_paths) # remove used proofs from wallet and add new ones used_secrets = [p.secret for p in proofs] self.proofs = list(filter(lambda p: p.secret not in used_secrets, self.proofs)) # add new proofs to wallet - self.proofs += frst_proofs + scnd_proofs + self.proofs += new_proofs # store new proofs in database - await self._store_proofs(frst_proofs + scnd_proofs) - # invalidate used proofs - async with self.db.connect() as conn: - for proof in proofs: - await invalidate_proof(proof, db=self.db, conn=conn) - return frst_proofs, scnd_proofs + await self._store_proofs(new_proofs) + # invalidate used proofs in database + for proof in proofs: + await invalidate_proof(proof, db=self.db) + + keep_proofs = new_proofs[: len(frst_outputs)] + send_proofs = new_proofs[len(frst_outputs) :] + return keep_proofs, send_proofs async def pay_lightning( self, proofs: List[Proof], invoice: str, fee_reserve_sat: int diff --git a/pyproject.toml b/pyproject.toml index 26ba0bd..8d0b020 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.12.3" +version = "0.13.0" description = "Ecash wallet and mint" authors = ["calle "] license = "MIT" diff --git a/tests/test_core.py b/tests/test_core.py index 470b140..d95506d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -27,6 +27,13 @@ def test_tokenv3_deserialize_serialize(): assert token.serialize() == token_str +def test_tokenv3_deserialize_with_memo(): + token_str = "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjVQRjFnNFpWMnciLCAiQyI6ICIwM2FiNTgwYWQ5NTc3OGVkNTI5NmY4YmVlNjU1ZGJkN2Q2NDJmNWQzMmRlOGUyNDg0NzdlMGI0ZDZhYTg2M2ZjZDUifSwgeyJpZCI6ICJKZWhaTFU2bkNwUmQiLCAiYW1vdW50IjogOCwgInNlY3JldCI6ICJzNklwZXh3SGNxcXVLZDZYbW9qTDJnIiwgIkMiOiAiMDIyZDAwNGY5ZWMxNmE1OGFkOTAxNGMyNTliNmQ2MTRlZDM2ODgyOWYwMmMzODc3M2M0NzIyMWY0OTYxY2UzZjIzIn1dLCAibWludCI6ICJodHRwOi8vbG9jYWxob3N0OjMzMzgifV0sICJtZW1vIjogIlRlc3QgbWVtbyJ9" + token = TokenV3.deserialize(token_str) + assert token.serialize() == token_str + assert token.memo == "Test memo" + + def test_calculate_number_of_blank_outputs(): # Example from NUT-08 specification. fee_reserve_sat = 1000 diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 12f8782..6aaeb37 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -198,7 +198,8 @@ async def test_split_more_than_balance(wallet1: Wallet): await wallet1.mint(64) await assert_err( wallet1.split(wallet1.proofs, 128), - "Mint Error: split amount is higher than the total sum.", + # "Mint Error: inputs do not have same amount as outputs", + "amount too large.", ) assert wallet1.balance == 64 @@ -274,7 +275,7 @@ async def test_split_invalid_amount(wallet1: Wallet): await wallet1.mint(64) await assert_err( wallet1.split(wallet1.proofs, -1), - "Mint Error: invalid split amount: -1", + "amount must be positive.", )