diff --git a/README.md b/README.md index f2942fb..31a3c8e 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ cashu info Returns: ```bash -Version: 0.9.4 +Version: 0.10.0 Debug: False Cashu dir: /home/user/.cashu Wallet: wallet diff --git a/cashu/core/base.py b/cashu/core/base.py index a386941..a6237a9 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -139,11 +139,13 @@ class GetMintResponse(BaseModel): class PostMeltRequest(BaseModel): proofs: List[Proof] pr: str + outputs: Union[List[BlindedMessage], None] class GetMeltResponse(BaseModel): paid: Union[bool, None] preimage: Union[str, None] + change: Union[List[BlindedSignature], None] = None # ------- API: SPLIT ------- diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index aa53bd5..27c1190 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -197,7 +197,7 @@ async def get_lightning_invoice( """, (hash,), ) - return Invoice(**row) + return Invoice(**row) if row else None async def update_lightning_invoice( diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index a170543..190f8f5 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1,5 +1,5 @@ import math -from typing import Dict, List, Optional, Set +from typing import Dict, List, Literal, Optional, Set, Union from loguru import logger @@ -23,8 +23,6 @@ from cashu.core.split import amount_split from cashu.lightning.base import Wallet from cashu.mint.crud import LedgerCrud -# from starlette_context import context - class Ledger: def __init__( @@ -49,7 +47,15 @@ class Ledger: self.proofs_used = set(proofs_used) async def load_keyset(self, derivation_path, autosave=True): - """Load current keyset keyset or generate new one.""" + """Load current keyset keyset or generate new one. + + Args: + derivation_path (_type_): Derivation path from which the keyset is generated. + autosave (bool, optional): Store newly-generated keyset if not already in database. Defaults to True. + + Returns: + MintKeyset: Keyset + """ keyset = MintKeyset( seed=self.master_key, derivation_path=derivation_path, @@ -70,7 +76,12 @@ class Ledger: return keyset async def init_keysets(self, autosave=True): - """Loads all keysets from db.""" + """Loads all keysets from db. + + Args: + autosave (bool, optional): Whether the keyset should be saved if it is + not in the database yet. Will be passed to `self.load_keyset`. Defaults to True. + """ # load all past keysets from db tmp_keysets: List[MintKeyset] = await self.crud.get_keyset(db=self.db) self.keysets = MintKeysets(tmp_keysets) @@ -84,8 +95,16 @@ class Ledger: async def _generate_promises( self, B_s: List[BlindedMessage], keyset: Optional[MintKeyset] = None - ): - """Generates promises that sum to the given amount.""" + ) -> list[BlindedSignature]: + """Generates promises that sum to the given amount. + + Args: + B_s (List[BlindedMessage]): _description_ + keyset (Optional[MintKeyset], optional): _description_. Defaults to None. + + Returns: + list[BlindedSignature]: _description_ + """ return [ await self._generate_promise( b.amount, PublicKey(bytes.fromhex(b.B_), raw=True), keyset @@ -95,8 +114,17 @@ class Ledger: async def _generate_promise( self, amount: int, B_: PublicKey, keyset: Optional[MintKeyset] = None - ): - """Generates a promise for given amount and returns a pair (amount, C').""" + ) -> BlindedSignature: + """Generates a promise (Blind signature) for given amount and returns a pair (amount, C'). + + Args: + amount (int): Amount of the promise. + B_ (PublicKey): Blinded secret (point on curve) + keyset (Optional[MintKeyset], optional): Which keyset to use. Private keys will be taken from this keyset. Defaults to None. + + Returns: + BlindedSignature: Generated promise. + """ keyset = keyset if keyset else self.keyset private_key_amount = keyset.private_keys[amount] C_ = b_dhke.step2_bob(B_, private_key_amount) @@ -109,7 +137,7 @@ class Ledger: """Checks whether the proof was already spent.""" return not proof.secret in self.proofs_used - def _verify_secret_criteria(self, proof: Proof): + 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 == "": raise Exception("no secret in proof.") @@ -133,7 +161,7 @@ class Ledger: C = PublicKey(bytes.fromhex(proof.C), raw=True) return b_dhke.verify(private_key_amount, C, proof.secret) - def _verify_script(self, idx: int, proof: Proof): + def _verify_script(self, idx: int, proof: Proof) -> bool: """ Verify bitcoin script in proof.script commited to by
in proof.secret. proof.secret format: P2SH:
: @@ -163,7 +191,9 @@ class Ledger: ), f"secret does not contain correct P2SH address: {proof.secret.split(':')[1]} is not {txin_p2sh_address}." return valid - def _verify_outputs(self, total: int, amount: int, outputs: List[BlindedMessage]): + def _verify_outputs( + self, total: int, amount: int, 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) @@ -172,19 +202,19 @@ class Ledger: given = [o.amount for o in outputs] return given == expected - def _verify_no_duplicate_proofs(self, proofs: List[Proof]): + def _verify_no_duplicate_proofs(self, proofs: List[Proof]) -> bool: secrets = [p.secret for p in proofs] if len(secrets) != len(list(set(secrets))): return False return True - def _verify_no_duplicate_outputs(self, outputs: List[BlindedMessage]): + def _verify_no_duplicate_outputs(self, outputs: List[BlindedMessage]) -> bool: B_s = [od.B_ for od in outputs] if len(B_s) != len(list(set(B_s))): return False return True - def _verify_split_amount(self, amount: int): + 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) @@ -192,7 +222,7 @@ class Ledger: # For better error message raise Exception("invalid split amount: " + str(amount)) - def _verify_amount(self, amount: int): + def _verify_amount(self, amount: int) -> int: """Any amount used should be a positive integer not larger than 2^MAX_ORDER.""" valid = ( isinstance(amount, int) and amount > 0 and amount < 2**settings.max_order @@ -203,14 +233,24 @@ class Ledger: def _verify_equation_balanced( self, proofs: List[Proof], outs: List[BlindedSignature] - ): + ) -> None: """Verify that Σoutputs - Σinputs = 0.""" 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 async def _request_lightning_invoice(self, amount: int): - """Returns an invoice from the Lightning backend.""" + """Generate a Lightning invoice using the funding source backend. + + Args: + amount (int): Amount of invoice (in Satoshis) + + Raises: + Exception: Error with funding source. + + Returns: + Tuple[str, str]: Bolt11 invoice and payment hash (for lookup) + """ error, balance = await self.lightning.status() if error: raise Exception(f"Lightning wallet not responding: {error}") @@ -222,12 +262,26 @@ class Ledger: ) = await self.lightning.create_invoice(amount, "cashu deposit") return payment_request, checking_id - async def _check_lightning_invoice(self, amount: int, payment_hash: str): + async def _check_lightning_invoice( + self, amount: int, payment_hash: str + ) -> Literal[True]: + """Checks with the Lightning backend whether an invoice with this payment_hash was paid. + + Args: + amount (int): Amount of the outputs the wallet wants in return (in Satoshis). + payment_hash (str): Payment hash of Lightning invoice (for lookup). + + Raises: + Exception: Invoice not found. + Exception: Tokens for invoice already issued. + Exception: Amount larger than invoice amount. + Exception: Invoice not paid yet + e: Update database and pass through error. + + Returns: + bool: True if invoice has been paid, else False """ - Checks with the Lightning backend whether an invoice with this payment_hash was paid. - Raises exception if invoice is unpaid. - """ - invoice: Invoice = await self.crud.get_lightning_invoice( + invoice: Union[Invoice, None] = await self.crud.get_lightning_invoice( hash=payment_hash, db=self.db ) if invoice is None: @@ -259,7 +313,18 @@ class Ledger: raise e async def _pay_lightning_invoice(self, invoice: str, fee_limit_msat: int): - """Returns an invoice from the Lightning backend.""" + """Pays a Lightning invoice via the funding source backend. + + Args: + invoice (str): Bolt11 Lightning invoice + fee_limit_msat (int): Maximum fee reserve for payment (in Millisatoshi) + + Raises: + Exception: Funding source error. + + Returns: + Tuple[bool, string, int]: Returns payment status, preimage of invoice, paid fees (in Millisatoshi) + """ error, _ = await self.lightning.status() if error: raise Exception(f"Lightning wallet not responding: {error}") @@ -270,12 +335,16 @@ class Ledger: preimage, error_message, ) = await self.lightning.pay_invoice(invoice, fee_limit_msat=fee_limit_msat) - return ok, preimage + # make sure that fee is positive + fee_msat = abs(fee_msat) if fee_msat else fee_msat + return ok, preimage, fee_msat async def _invalidate_proofs(self, proofs: List[Proof]): - """ - Adds secrets of proofs to the list of known secrets and stores them in the db. - Removes proofs from pending table. + """Adds secrets of proofs to the list of known secrets and stores them in the db. + Removes proofs from pending table. This is executed if the ecash has been redeemed. + + Args: + proofs (List[Proof]): Proofs to add to known secret table. """ # Mark proofs as used and prepare new promises proof_msgs = set([p.secret for p in proofs]) @@ -285,9 +354,14 @@ class Ledger: await self.crud.invalidate_proof(proof=p, db=self.db) async def _set_proofs_pending(self, proofs: List[Proof]): - """ - If none of the proofs is in the pending table (_validate_proofs_pending), adds proofs to + """If none of the proofs is in the pending table (_validate_proofs_pending), adds proofs to the list of pending proofs or removes them. Used as a mutex for proofs. + + Args: + proofs (List[Proof]): Proofs to add to pending table. + + Raises: + Exception: At least one proof already in pending table. """ # first we check whether these proofs are pending aready await self._validate_proofs_pending(proofs) @@ -298,7 +372,11 @@ class Ledger: raise Exception("proofs already pending.") async def _unset_proofs_pending(self, proofs: List[Proof]): - """Deletes proofs from pending table.""" + """Deletes proofs from pending table. + + Args: + proofs (List[Proof]): Proofs to delete. + """ # we try: except: this block in order to avoid that any errors here # could block the _invalidate_proofs() call that happens afterwards. try: @@ -309,7 +387,14 @@ class Ledger: pass async def _validate_proofs_pending(self, proofs: List[Proof]): - """Checks if any of the provided proofs is in the pending proofs table. Raises exception for at least one match.""" + """Checks if any of the provided proofs is in the pending proofs table. + + Args: + proofs (List[Proof]): Proofs to check. + + Raises: + Exception: At least one of the proofs is in the pending table. + """ proofs_pending = await self.crud.get_proofs_pending(db=self.db) for p in proofs: for pp in proofs_pending: @@ -317,7 +402,17 @@ class Ledger: raise Exception("proofs are pending.") async def _verify_proofs(self, proofs: List[Proof]): - """Checks a series of criteria for the verification of proofs.""" + """Checks a series of criteria for the verification of proofs. + + Args: + proofs (List[Proof]): List of proofs to check. + + Raises: + Exception: Scripts did not validate. + Exception: Criteria for provided secrets not met. + Exception: Duplicate proofs provided. + Exception: BDHKE verification failed. + """ # Verify scripts if not all([self._verify_script(i, p) for i, p in enumerate(proofs)]): raise Exception("script validation failed.") @@ -331,16 +426,86 @@ class Ledger: if not all([self._verify_proof_bdhke(p) for p in proofs]): raise Exception("could not verify proofs.") + async def _generate_change_promises( + self, + total_provided: int, + invoice_amount: int, + ln_fee_msat: int, + outputs: List[BlindedMessage], + ): + """Generates a set of new promises (blinded signatures) from a set of blank outputs + (outputs with no or ignored amount) by looking at the difference between the Lightning + fee reserve provided by the wallet and the actual Lightning fee paid by the mint. + + If there is a positive difference, produces maximum `n_return_outputs` new outputs + with values close or equal to the fee difference. We can't be sure that we hit the + fee perfectly because we can only work with a limited set of blanket outputs and + their values are limited to 2^n. + + Args: + total_provided (int): Amount of the proofs provided by the wallet. + invoice_amount (int): Amount of the invoice to be paid. + ln_fee_msat (int): Actually paid Lightning network fees. + outputs (List[BlindedMessage]): Outputs to sign for returning the overpaid fees. + + Raises: + Exception: Output validation failed. + + Returns: + List[BlindedSignature]: Signatures on the outputs. + """ + # we make sure that the fee is positive + ln_fee_msat = abs(ln_fee_msat) + # maximum number of change outputs (must be in consensus with wallet) + n_return_outputs = 4 + ln_fee_sat = math.ceil(ln_fee_msat / 1000) + user_paid_fee_sat = total_provided - invoice_amount + logger.debug( + f"Lightning fee was: {ln_fee_sat}. User paid: {user_paid_fee_sat}. Returning difference." + ) + if user_paid_fee_sat - ln_fee_sat > 0 and outputs is not None: + # we will only accept at maximum n_return_outputs outputs + assert len(outputs) <= n_return_outputs, Exception( + "too many change outputs provided" + ) + + return_amounts = amount_split(user_paid_fee_sat - ln_fee_sat) + # we only need as many outputs as we have change to return + outputs = outputs[: len(return_amounts)] + # we sort the return_amounts in descending order so we only + # take the largest values in the next step + return_amounts_sorted = sorted(return_amounts, reverse=True) + # we need to imprint these amounts into the blanket outputs + for i in range(len(outputs)): + outputs[i].amount = return_amounts_sorted[i] + if not self._verify_no_duplicate_outputs(outputs): + raise Exception("duplicate promises.") + return_promises = await self._generate_promises(outputs) + return return_promises + else: + return [] + # Public methods def get_keyset(self, keyset_id: Optional[str] = None): + """Returns a dictionary of hex public keys of a specific keyset for each supported amount""" if keyset_id and keyset_id not in self.keysets.keysets: raise Exception("keyset does not exist") keyset = self.keysets.keysets[keyset_id] if keyset_id else self.keyset assert keyset.public_keys, Exception("no public keys for this keyset") return {a: p.serialize().hex() for a, p in keyset.public_keys.items()} - async def request_mint(self, amount): - """Returns Lightning invoice and stores it in the db.""" + async def request_mint(self, amount: int): + """Returns Lightning invoice and stores it in the db. + + Args: + amount (int): Amount of the mint request in Satoshis. + + Raises: + Exception: Invoice creation failed. + + Returns: + Tuple[str, str]: Bolt11 invoice and payment hash (for looking it up later) + """ payment_request, checking_id = await self._request_lightning_invoice(amount) assert payment_request, Exception( "could not fetch invoice from Lightning backend" @@ -356,10 +521,24 @@ class Ledger: async def mint( self, B_s: List[BlindedMessage], - payment_hash=None, + payment_hash: Optional[str] = None, keyset: Optional[MintKeyset] = None, ): - """Mints a promise for coins for B_.""" + """Mints a promise for coins for B_. + + Args: + B_s (List[BlindedMessage]): Outputs (blinded messages) to sign. + payment_hash (Optional[str], optional): Payment hash of (paid) Lightning invoice. Defaults to None. + keyset (Optional[MintKeyset], optional): Keyset to use. If not provided, uses active keyset. Defaults to None. + + Raises: + Exception: Lightning is turned on but no payment hash is provided. + e: Something went wrong with the invoice check. + Exception: Amount too large. + + Returns: + List[BlindedSignature]: Signatures on the outputs. + """ amounts = [b.amount for b in B_s] amount = sum(amounts) # check if lightning invoice was paid @@ -380,8 +559,22 @@ class Ledger: promises = await self._generate_promises(B_s, keyset) return promises - async def melt(self, proofs: List[Proof], invoice: str): - """Invalidates proofs and pays a Lightning invoice.""" + async def melt( + self, proofs: List[Proof], invoice: str, outputs: Optional[List[BlindedMessage]] + ): + """Invalidates proofs and pays a Lightning invoice. + + Args: + proofs (List[Proof]): Proofs provided for paying the Lightning invoice + invoice (str): bolt11 Lightning invoice. + outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet. + + Raises: + e: Lightning payment unsuccessful + + Returns: + List[BlindedMessage]: Signed outputs for returning overpaid fees to wallet. + """ # validate and set proofs as pending await self._set_proofs_pending(proofs) @@ -391,32 +584,69 @@ class Ledger: total_provided = sum_proofs(proofs) invoice_obj = bolt11.decode(invoice) - amount = math.ceil(invoice_obj.amount_msat / 1000) + invoice_amount = math.ceil(invoice_obj.amount_msat / 1000) fees_msat = await self.check_fees(invoice) - assert total_provided >= amount + fees_msat / 1000, Exception( + assert total_provided >= invoice_amount + fees_msat / 1000, Exception( "provided proofs not enough for Lightning payment." ) + # promises to return for overpaid fees + return_promises: List[BlindedSignature] = [] + if settings.lightning: - status, preimage = await self._pay_lightning_invoice(invoice, fees_msat) + status, preimage, fee_msat = await self._pay_lightning_invoice( + invoice, fees_msat + ) else: - status, preimage = True, "preimage" + status, preimage, fee_msat = True, "preimage", 0 + if status == True: await self._invalidate_proofs(proofs) + + # prepare change to compensate wallet for overpaid fees + assert fee_msat is not None, Exception("fees not valid") + if outputs: + return_promises = await self._generate_change_promises( + total_provided=total_provided, + invoice_amount=invoice_amount, + ln_fee_msat=fee_msat, + outputs=outputs, + ) + except Exception as e: raise e finally: # delete proofs from pending list await self._unset_proofs_pending(proofs) - return status, preimage + return status, preimage, return_promises async def check_spendable(self, proofs: List[Proof]): - """Checks if all provided proofs are valid and still spendable (i.e. have not been spent).""" + """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. + + 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 + and which isn't. + + Args: + proofs (List[Proof]): List of proofs to check. + + Returns: + List[bool]: List of which proof is still spendable (True if still spendable, else False) + """ return [self._check_spendable(p) for p in proofs] async def check_fees(self, pr: str): - """Returns the fees (in msat) required to pay this pr.""" + """Returns the fee reserve (in sat) that a wallet must add to its proofs + in order to pay a Lightning invoice. + + Args: + pr (str): Bolt11 encoded payment request. Lightning invoice. + + Returns: + int: Fee in Satoshis. + """ # hack: check if it's internal, if it exists, it will return paid = False, # if id does not exist (not internal), it returns paid = None if settings.lightning: @@ -428,7 +658,8 @@ class Ledger: amount = 0 internal = True fees_msat = fee_reserve(amount * 1000, internal) - return fees_msat + fee_sat = math.ceil(fees_msat / 1000) + return fee_sat async def split( self, @@ -437,7 +668,21 @@ class Ledger: outputs: List[BlindedMessage], keyset: Optional[MintKeyset] = None, ): - """Consumes proofs and prepares new promises based on the amount split.""" + """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. + + Args: + proofs (List[Proof]): Proofs to be invalidated for the split. + amount (int): Amount at which the split should happen. + outputs (List[BlindedMessage]): New outputs that should be signed in return. + keyset (Optional[MintKeyset], optional): Keyset to use. Uses default keyset if not given. Defaults to None. + + Raises: + Exception: Validation of proofs or outputs failed + + Returns: + Tuple[List[BlindSignature],List[BlindSignature]]: Promises on both sides of the split. + """ # set proofs as pending await self._set_proofs_pending(proofs) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index f927d6a..2cf29d6 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -113,11 +113,13 @@ async def melt(payload: PostMeltRequest) -> Union[CashuError, GetMeltResponse]: Requests tokens to be destroyed and sent out via Lightning. """ try: - ok, preimage = await ledger.melt(payload.proofs, payload.pr) - resp = GetMeltResponse(paid=ok, preimage=preimage) + ok, preimage, change_promises = await ledger.melt( + payload.proofs, payload.pr, payload.outputs + ) + resp = GetMeltResponse(paid=ok, preimage=preimage, change=change_promises) + return resp except Exception as exc: return CashuError(code=0, error=str(exc)) - return resp @router.post( @@ -144,8 +146,8 @@ async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse: Used by wallets for figuring out the fees they need to supply together with the payment amount. This is can be useful for checking whether an invoice is internal (Cashu-to-Cashu). """ - fees_msat = await ledger.check_fees(payload.pr) - return CheckFeesResponse(fee=fees_msat // 1000) + fees_sat = await ledger.check_fees(payload.pr) + return CheckFeesResponse(fee=fees_sat) @router.post("/split", name="Split", summary="Split proofs at a specified amount") diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index cf6c387..06e5cd4 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -113,21 +113,22 @@ async def pay(ctx: Context, invoice: str, yes: bool): wallet: Wallet = ctx.obj["WALLET"] await wallet.load_mint() wallet.status() - amount, fees = await wallet.get_pay_amount_with_fees(invoice) + total_amount, fee_reserve_sat = await wallet.get_pay_amount_with_fees(invoice) if not yes: click.confirm( - f"Pay {amount - fees} sat ({amount} sat incl. fees)?", + f"Pay {total_amount - fee_reserve_sat} sat ({total_amount} sat with potential fees)?", abort=True, default=True, ) print(f"Paying Lightning invoice ...") - assert amount > 0, "amount is not positive" - if wallet.available_balance < amount: + assert total_amount > 0, "amount is not positive" + if wallet.available_balance < total_amount: print("Error: Balance too low.") return - _, send_proofs = await wallet.split_to_send(wallet.proofs, amount) # type: ignore + _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) # type: ignore await wallet.pay_lightning(send_proofs, invoice) + await wallet.load_proofs() wallet.status() diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 4ed201c..b0b0fc4 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -18,6 +18,7 @@ from cashu.core.base import ( CheckFeesRequest, CheckSpendableRequest, CheckSpendableResponse, + GetMeltResponse, GetMintResponse, Invoice, KeysetsResponse, @@ -425,19 +426,22 @@ class LedgerAPI: return return_dict @async_set_requests - async def pay_lightning(self, proofs: List[Proof], invoice: str): + async def pay_lightning( + self, proofs: List[Proof], invoice: str, outputs: Optional[List[BlindedMessage]] + ): """ Accepts proofs and a lightning invoice to pay in exchange. """ - payload = PostMeltRequest(proofs=proofs, pr=invoice) + + payload = PostMeltRequest(proofs=proofs, pr=invoice, outputs=outputs) def _meltrequest_include_fields(proofs): """strips away fields from the model that aren't necessary for the /melt""" proofs_include = {"id", "amount", "secret", "C", "script"} return { - "amount": ..., - "pr": ..., "proofs": {i: proofs_include for i in range(len(proofs))}, + "pr": ..., + "outputs": ..., } resp = self.s.post( @@ -447,7 +451,7 @@ class LedgerAPI: resp.raise_for_status() return_dict = resp.json() self.raise_on_error(return_dict) - return return_dict + return GetMeltResponse.parse_obj(return_dict) class Wallet(LedgerAPI): @@ -553,10 +557,8 @@ class Wallet(LedgerAPI): frst_proofs, scnd_proofs = await super().split(proofs, amount, scnd_secret) if len(frst_proofs) == 0 and len(scnd_proofs) == 0: raise Exception("received no splits.") - used_secrets = [p["secret"] for p in proofs] - self.proofs = list( - filter(lambda p: p["secret"] not in used_secrets, self.proofs) - ) + used_secrets = [p.secret for p in proofs] + self.proofs = list(filter(lambda p: p.secret not in used_secrets, self.proofs)) self.proofs += frst_proofs + scnd_proofs await self._store_proofs(frst_proofs + scnd_proofs) for proof in proofs: @@ -565,20 +567,41 @@ class Wallet(LedgerAPI): async def pay_lightning(self, proofs: List[Proof], invoice: str): """Pays a lightning invoice""" - status = await super().pay_lightning(proofs, invoice) - if status["paid"] == True: + + # generate outputs for the change for overpaid fees + # we will generate four blanked outputs that the mint will + # imprint with value depending on the fees we overpaid + n_return_outputs = 4 + secrets = [self._generate_secret() for _ in range(n_return_outputs)] + outputs, rs = self._construct_outputs(n_return_outputs * [1], secrets) + + status = await super().pay_lightning(proofs, invoice, outputs) + + if status.paid == True: + # the payment was successful await self.invalidate(proofs) invoice_obj = Invoice( amount=-sum_proofs(proofs), pr=invoice, - preimage=status.get("preimage"), + preimage=status.preimage, paid=True, time_paid=time.time(), ) await store_lightning_invoice(db=self.db, invoice=invoice_obj) + + # handle change and produce proofs + if status.change: + change_proofs = self._construct_proofs( + status.change, + secrets[: len(status.change)], + rs[: len(status.change)], + ) + logger.debug(f"Received change: {sum_proofs(change_proofs)} sat") + await self._store_proofs(change_proofs) + else: raise Exception("could not pay invoice.") - return status["paid"] + return status.paid async def check_spendable(self, proofs): return await super().check_spendable(proofs) @@ -735,6 +758,7 @@ class Wallet(LedgerAPI): decoded_invoice: InvoiceBolt11 = bolt11.decode(invoice) # check if it's an internal payment fees = int((await self.check_fees(invoice))["fee"]) + logger.debug(f"Mint wants {fees} sat as fee reserve.") amount = math.ceil((decoded_invoice.amount_msat + fees * 1000) / 1000) # 1% fee return amount, fees diff --git a/pyproject.toml b/pyproject.toml index 93fa906..5c05b26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.9.4" +version = "0.10.0" description = "Ecash wallet and mint." authors = ["calle "] license = "MIT" diff --git a/setup.py b/setup.py index 320c90b..86f0ef7 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cashu.wallet.cli.cli:cli"]} setuptools.setup( name="cashu", - version="0.9.4", + version="0.10.0", description="Ecash wallet and mint for Bitcoin Lightning", long_description=long_description, long_description_content_type="text/markdown",