NUT-08 Lightning fee return (#114)

* skeleton

* works

* comments

* docsctrings ledger.py

* bump version to 0.10.

* fixes mypy stuff

* make format

* remove unwanted changes
This commit is contained in:
calle
2023-03-16 01:28:33 +01:00
committed by GitHub
parent 9ae222740a
commit 70828b59d5
9 changed files with 349 additions and 75 deletions

View File

@@ -115,7 +115,7 @@ cashu info
Returns: Returns:
```bash ```bash
Version: 0.9.4 Version: 0.10.0
Debug: False Debug: False
Cashu dir: /home/user/.cashu Cashu dir: /home/user/.cashu
Wallet: wallet Wallet: wallet

View File

@@ -139,11 +139,13 @@ class GetMintResponse(BaseModel):
class PostMeltRequest(BaseModel): class PostMeltRequest(BaseModel):
proofs: List[Proof] proofs: List[Proof]
pr: str pr: str
outputs: Union[List[BlindedMessage], None]
class GetMeltResponse(BaseModel): class GetMeltResponse(BaseModel):
paid: Union[bool, None] paid: Union[bool, None]
preimage: Union[str, None] preimage: Union[str, None]
change: Union[List[BlindedSignature], None] = None
# ------- API: SPLIT ------- # ------- API: SPLIT -------

View File

@@ -197,7 +197,7 @@ async def get_lightning_invoice(
""", """,
(hash,), (hash,),
) )
return Invoice(**row) return Invoice(**row) if row else None
async def update_lightning_invoice( async def update_lightning_invoice(

View File

@@ -1,5 +1,5 @@
import math import math
from typing import Dict, List, Optional, Set from typing import Dict, List, Literal, Optional, Set, Union
from loguru import logger from loguru import logger
@@ -23,8 +23,6 @@ from cashu.core.split import amount_split
from cashu.lightning.base import Wallet from cashu.lightning.base import Wallet
from cashu.mint.crud import LedgerCrud from cashu.mint.crud import LedgerCrud
# from starlette_context import context
class Ledger: class Ledger:
def __init__( def __init__(
@@ -49,7 +47,15 @@ class Ledger:
self.proofs_used = set(proofs_used) self.proofs_used = set(proofs_used)
async def load_keyset(self, derivation_path, autosave=True): 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( keyset = MintKeyset(
seed=self.master_key, seed=self.master_key,
derivation_path=derivation_path, derivation_path=derivation_path,
@@ -70,7 +76,12 @@ class Ledger:
return keyset return keyset
async def init_keysets(self, autosave=True): 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 # load all past keysets from db
tmp_keysets: List[MintKeyset] = await self.crud.get_keyset(db=self.db) tmp_keysets: List[MintKeyset] = await self.crud.get_keyset(db=self.db)
self.keysets = MintKeysets(tmp_keysets) self.keysets = MintKeysets(tmp_keysets)
@@ -84,8 +95,16 @@ class Ledger:
async def _generate_promises( async def _generate_promises(
self, B_s: List[BlindedMessage], keyset: Optional[MintKeyset] = None self, B_s: List[BlindedMessage], keyset: Optional[MintKeyset] = None
): ) -> list[BlindedSignature]:
"""Generates promises that sum to the given amount.""" """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 [ return [
await self._generate_promise( await self._generate_promise(
b.amount, PublicKey(bytes.fromhex(b.B_), raw=True), keyset b.amount, PublicKey(bytes.fromhex(b.B_), raw=True), keyset
@@ -95,8 +114,17 @@ class Ledger:
async def _generate_promise( async def _generate_promise(
self, amount: int, B_: PublicKey, keyset: Optional[MintKeyset] = None self, amount: int, B_: PublicKey, keyset: Optional[MintKeyset] = None
): ) -> BlindedSignature:
"""Generates a promise for given amount and returns a pair (amount, C').""" """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 keyset = keyset if keyset else self.keyset
private_key_amount = keyset.private_keys[amount] private_key_amount = keyset.private_keys[amount]
C_ = b_dhke.step2_bob(B_, private_key_amount) C_ = b_dhke.step2_bob(B_, private_key_amount)
@@ -109,7 +137,7 @@ class Ledger:
"""Checks whether the proof was already spent.""" """Checks whether the proof was already spent."""
return not proof.secret in self.proofs_used 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).""" """Verifies that a secret is present and is not too long (DOS prevention)."""
if proof.secret is None or proof.secret == "": if proof.secret is None or proof.secret == "":
raise Exception("no secret in proof.") raise Exception("no secret in proof.")
@@ -133,7 +161,7 @@ class Ledger:
C = PublicKey(bytes.fromhex(proof.C), raw=True) C = PublicKey(bytes.fromhex(proof.C), raw=True)
return b_dhke.verify(private_key_amount, C, proof.secret) 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 <address> in proof.secret. Verify bitcoin script in proof.script commited to by <address> in proof.secret.
proof.secret format: P2SH:<address>:<secret> proof.secret format: P2SH:<address>:<secret>
@@ -163,7 +191,9 @@ class Ledger:
), f"secret does not contain correct P2SH address: {proof.secret.split(':')[1]} is not {txin_p2sh_address}." ), f"secret does not contain correct P2SH address: {proof.secret.split(':')[1]} is not {txin_p2sh_address}."
return valid 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""" """Verifies the expected split was correctly computed"""
frst_amt, scnd_amt = total - amount, amount # we have two amounts to split to frst_amt, scnd_amt = total - amount, amount # we have two amounts to split to
frst_outputs = amount_split(frst_amt) frst_outputs = amount_split(frst_amt)
@@ -172,19 +202,19 @@ class Ledger:
given = [o.amount for o in outputs] given = [o.amount for o in outputs]
return given == expected 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] secrets = [p.secret for p in proofs]
if len(secrets) != len(list(set(secrets))): if len(secrets) != len(list(set(secrets))):
return False return False
return True 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] B_s = [od.B_ for od in outputs]
if len(B_s) != len(list(set(B_s))): if len(B_s) != len(list(set(B_s))):
return False return False
return True 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.""" """Split amount like output amount can't be negative or too big."""
try: try:
self._verify_amount(amount) self._verify_amount(amount)
@@ -192,7 +222,7 @@ class Ledger:
# For better error message # For better error message
raise Exception("invalid split amount: " + str(amount)) 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.""" """Any amount used should be a positive integer not larger than 2^MAX_ORDER."""
valid = ( valid = (
isinstance(amount, int) and amount > 0 and amount < 2**settings.max_order isinstance(amount, int) and amount > 0 and amount < 2**settings.max_order
@@ -203,14 +233,24 @@ class Ledger:
def _verify_equation_balanced( def _verify_equation_balanced(
self, proofs: List[Proof], outs: List[BlindedSignature] self, proofs: List[Proof], outs: List[BlindedSignature]
): ) -> None:
"""Verify that Σoutputs - Σinputs = 0.""" """Verify that Σoutputs - Σinputs = 0."""
sum_inputs = sum(self._verify_amount(p.amount) for p in proofs) sum_inputs = sum(self._verify_amount(p.amount) for p in proofs)
sum_outputs = sum(self._verify_amount(p.amount) for p in outs) sum_outputs = sum(self._verify_amount(p.amount) for p in outs)
assert sum_outputs - sum_inputs == 0 assert sum_outputs - sum_inputs == 0
async def _request_lightning_invoice(self, amount: int): 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() error, balance = await self.lightning.status()
if error: if error:
raise Exception(f"Lightning wallet not responding: {error}") raise Exception(f"Lightning wallet not responding: {error}")
@@ -222,12 +262,26 @@ class Ledger:
) = await self.lightning.create_invoice(amount, "cashu deposit") ) = await self.lightning.create_invoice(amount, "cashu deposit")
return payment_request, checking_id 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. invoice: Union[Invoice, None] = await self.crud.get_lightning_invoice(
Raises exception if invoice is unpaid.
"""
invoice: Invoice = await self.crud.get_lightning_invoice(
hash=payment_hash, db=self.db hash=payment_hash, db=self.db
) )
if invoice is None: if invoice is None:
@@ -259,7 +313,18 @@ class Ledger:
raise e raise e
async def _pay_lightning_invoice(self, invoice: str, fee_limit_msat: int): 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() error, _ = await self.lightning.status()
if error: if error:
raise Exception(f"Lightning wallet not responding: {error}") raise Exception(f"Lightning wallet not responding: {error}")
@@ -270,12 +335,16 @@ class Ledger:
preimage, preimage,
error_message, error_message,
) = await self.lightning.pay_invoice(invoice, fee_limit_msat=fee_limit_msat) ) = 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]): async def _invalidate_proofs(self, proofs: List[Proof]):
""" """Adds secrets of proofs to the list of known secrets and stores them in the db.
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.
Removes proofs from pending table.
Args:
proofs (List[Proof]): Proofs to add to known secret table.
""" """
# Mark proofs as used and prepare new promises # Mark proofs as used and prepare new promises
proof_msgs = set([p.secret for p in proofs]) 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) await self.crud.invalidate_proof(proof=p, db=self.db)
async def _set_proofs_pending(self, proofs: List[Proof]): 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. 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 # first we check whether these proofs are pending aready
await self._validate_proofs_pending(proofs) await self._validate_proofs_pending(proofs)
@@ -298,7 +372,11 @@ class Ledger:
raise Exception("proofs already pending.") raise Exception("proofs already pending.")
async def _unset_proofs_pending(self, proofs: List[Proof]): 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 # we try: except: this block in order to avoid that any errors here
# could block the _invalidate_proofs() call that happens afterwards. # could block the _invalidate_proofs() call that happens afterwards.
try: try:
@@ -309,7 +387,14 @@ class Ledger:
pass pass
async def _validate_proofs_pending(self, proofs: List[Proof]): 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) proofs_pending = await self.crud.get_proofs_pending(db=self.db)
for p in proofs: for p in proofs:
for pp in proofs_pending: for pp in proofs_pending:
@@ -317,7 +402,17 @@ class Ledger:
raise Exception("proofs are pending.") raise Exception("proofs are pending.")
async def _verify_proofs(self, proofs: List[Proof]): 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 # Verify scripts
if not all([self._verify_script(i, p) for i, p in enumerate(proofs)]): if not all([self._verify_script(i, p) for i, p in enumerate(proofs)]):
raise Exception("script validation failed.") raise Exception("script validation failed.")
@@ -331,16 +426,86 @@ class Ledger:
if not all([self._verify_proof_bdhke(p) for p in proofs]): if not all([self._verify_proof_bdhke(p) for p in proofs]):
raise Exception("could not verify 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 # Public methods
def get_keyset(self, keyset_id: Optional[str] = None): 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: if keyset_id and keyset_id not in self.keysets.keysets:
raise Exception("keyset does not exist") raise Exception("keyset does not exist")
keyset = self.keysets.keysets[keyset_id] if keyset_id else self.keyset keyset = self.keysets.keysets[keyset_id] if keyset_id else self.keyset
assert keyset.public_keys, Exception("no public keys for this 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()} return {a: p.serialize().hex() for a, p in keyset.public_keys.items()}
async def request_mint(self, amount): async def request_mint(self, amount: int):
"""Returns Lightning invoice and stores it in the db.""" """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) payment_request, checking_id = await self._request_lightning_invoice(amount)
assert payment_request, Exception( assert payment_request, Exception(
"could not fetch invoice from Lightning backend" "could not fetch invoice from Lightning backend"
@@ -356,10 +521,24 @@ class Ledger:
async def mint( async def mint(
self, self,
B_s: List[BlindedMessage], B_s: List[BlindedMessage],
payment_hash=None, payment_hash: Optional[str] = None,
keyset: Optional[MintKeyset] = 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] amounts = [b.amount for b in B_s]
amount = sum(amounts) amount = sum(amounts)
# check if lightning invoice was paid # check if lightning invoice was paid
@@ -380,8 +559,22 @@ class Ledger:
promises = await self._generate_promises(B_s, keyset) promises = await self._generate_promises(B_s, keyset)
return promises return promises
async def melt(self, proofs: List[Proof], invoice: str): async def melt(
"""Invalidates proofs and pays a Lightning invoice.""" 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 # validate and set proofs as pending
await self._set_proofs_pending(proofs) await self._set_proofs_pending(proofs)
@@ -391,32 +584,69 @@ class Ledger:
total_provided = sum_proofs(proofs) total_provided = sum_proofs(proofs)
invoice_obj = bolt11.decode(invoice) 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) 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." "provided proofs not enough for Lightning payment."
) )
# promises to return for overpaid fees
return_promises: List[BlindedSignature] = []
if settings.lightning: 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: else:
status, preimage = True, "preimage" status, preimage, fee_msat = True, "preimage", 0
if status == True: if status == True:
await self._invalidate_proofs(proofs) 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: except Exception as e:
raise e raise e
finally: finally:
# delete proofs from pending list # delete proofs from pending list
await self._unset_proofs_pending(proofs) await self._unset_proofs_pending(proofs)
return status, preimage return status, preimage, return_promises
async def check_spendable(self, proofs: List[Proof]): 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] return [self._check_spendable(p) for p in proofs]
async def check_fees(self, pr: str): 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, # 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 id does not exist (not internal), it returns paid = None
if settings.lightning: if settings.lightning:
@@ -428,7 +658,8 @@ class Ledger:
amount = 0 amount = 0
internal = True internal = True
fees_msat = fee_reserve(amount * 1000, internal) fees_msat = fee_reserve(amount * 1000, internal)
return fees_msat fee_sat = math.ceil(fees_msat / 1000)
return fee_sat
async def split( async def split(
self, self,
@@ -437,7 +668,21 @@ class Ledger:
outputs: List[BlindedMessage], outputs: List[BlindedMessage],
keyset: Optional[MintKeyset] = None, 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 # set proofs as pending
await self._set_proofs_pending(proofs) await self._set_proofs_pending(proofs)

View File

@@ -113,11 +113,13 @@ async def melt(payload: PostMeltRequest) -> Union[CashuError, GetMeltResponse]:
Requests tokens to be destroyed and sent out via Lightning. Requests tokens to be destroyed and sent out via Lightning.
""" """
try: try:
ok, preimage = await ledger.melt(payload.proofs, payload.pr) ok, preimage, change_promises = await ledger.melt(
resp = GetMeltResponse(paid=ok, preimage=preimage) payload.proofs, payload.pr, payload.outputs
)
resp = GetMeltResponse(paid=ok, preimage=preimage, change=change_promises)
return resp
except Exception as exc: except Exception as exc:
return CashuError(code=0, error=str(exc)) return CashuError(code=0, error=str(exc))
return resp
@router.post( @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. 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). This is can be useful for checking whether an invoice is internal (Cashu-to-Cashu).
""" """
fees_msat = await ledger.check_fees(payload.pr) fees_sat = await ledger.check_fees(payload.pr)
return CheckFeesResponse(fee=fees_msat // 1000) return CheckFeesResponse(fee=fees_sat)
@router.post("/split", name="Split", summary="Split proofs at a specified amount") @router.post("/split", name="Split", summary="Split proofs at a specified amount")

View File

@@ -113,21 +113,22 @@ async def pay(ctx: Context, invoice: str, yes: bool):
wallet: Wallet = ctx.obj["WALLET"] wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint() await wallet.load_mint()
wallet.status() 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: if not yes:
click.confirm( 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, abort=True,
default=True, default=True,
) )
print(f"Paying Lightning invoice ...") print(f"Paying Lightning invoice ...")
assert amount > 0, "amount is not positive" assert total_amount > 0, "amount is not positive"
if wallet.available_balance < amount: if wallet.available_balance < total_amount:
print("Error: Balance too low.") print("Error: Balance too low.")
return 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.pay_lightning(send_proofs, invoice)
await wallet.load_proofs()
wallet.status() wallet.status()

View File

@@ -18,6 +18,7 @@ from cashu.core.base import (
CheckFeesRequest, CheckFeesRequest,
CheckSpendableRequest, CheckSpendableRequest,
CheckSpendableResponse, CheckSpendableResponse,
GetMeltResponse,
GetMintResponse, GetMintResponse,
Invoice, Invoice,
KeysetsResponse, KeysetsResponse,
@@ -425,19 +426,22 @@ class LedgerAPI:
return return_dict return return_dict
@async_set_requests @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. 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): def _meltrequest_include_fields(proofs):
"""strips away fields from the model that aren't necessary for the /melt""" """strips away fields from the model that aren't necessary for the /melt"""
proofs_include = {"id", "amount", "secret", "C", "script"} proofs_include = {"id", "amount", "secret", "C", "script"}
return { return {
"amount": ...,
"pr": ...,
"proofs": {i: proofs_include for i in range(len(proofs))}, "proofs": {i: proofs_include for i in range(len(proofs))},
"pr": ...,
"outputs": ...,
} }
resp = self.s.post( resp = self.s.post(
@@ -447,7 +451,7 @@ class LedgerAPI:
resp.raise_for_status() resp.raise_for_status()
return_dict = resp.json() return_dict = resp.json()
self.raise_on_error(return_dict) self.raise_on_error(return_dict)
return return_dict return GetMeltResponse.parse_obj(return_dict)
class Wallet(LedgerAPI): class Wallet(LedgerAPI):
@@ -553,10 +557,8 @@ class Wallet(LedgerAPI):
frst_proofs, scnd_proofs = await super().split(proofs, amount, scnd_secret) frst_proofs, scnd_proofs = await super().split(proofs, amount, scnd_secret)
if len(frst_proofs) == 0 and len(scnd_proofs) == 0: if len(frst_proofs) == 0 and len(scnd_proofs) == 0:
raise Exception("received no splits.") raise Exception("received no splits.")
used_secrets = [p["secret"] for p in proofs] used_secrets = [p.secret for p in proofs]
self.proofs = list( self.proofs = list(filter(lambda p: p.secret not in used_secrets, self.proofs))
filter(lambda p: p["secret"] not in used_secrets, self.proofs)
)
self.proofs += frst_proofs + scnd_proofs self.proofs += frst_proofs + scnd_proofs
await self._store_proofs(frst_proofs + scnd_proofs) await self._store_proofs(frst_proofs + scnd_proofs)
for proof in proofs: for proof in proofs:
@@ -565,20 +567,41 @@ class Wallet(LedgerAPI):
async def pay_lightning(self, proofs: List[Proof], invoice: str): async def pay_lightning(self, proofs: List[Proof], invoice: str):
"""Pays a lightning invoice""" """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) await self.invalidate(proofs)
invoice_obj = Invoice( invoice_obj = Invoice(
amount=-sum_proofs(proofs), amount=-sum_proofs(proofs),
pr=invoice, pr=invoice,
preimage=status.get("preimage"), preimage=status.preimage,
paid=True, paid=True,
time_paid=time.time(), time_paid=time.time(),
) )
await store_lightning_invoice(db=self.db, invoice=invoice_obj) 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: else:
raise Exception("could not pay invoice.") raise Exception("could not pay invoice.")
return status["paid"] return status.paid
async def check_spendable(self, proofs): async def check_spendable(self, proofs):
return await super().check_spendable(proofs) return await super().check_spendable(proofs)
@@ -735,6 +758,7 @@ class Wallet(LedgerAPI):
decoded_invoice: InvoiceBolt11 = bolt11.decode(invoice) decoded_invoice: InvoiceBolt11 = bolt11.decode(invoice)
# check if it's an internal payment # check if it's an internal payment
fees = int((await self.check_fees(invoice))["fee"]) 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 amount = math.ceil((decoded_invoice.amount_msat + fees * 1000) / 1000) # 1% fee
return amount, fees return amount, fees

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "cashu" name = "cashu"
version = "0.9.4" version = "0.10.0"
description = "Ecash wallet and mint." description = "Ecash wallet and mint."
authors = ["calle <callebtc@protonmail.com>"] authors = ["calle <callebtc@protonmail.com>"]
license = "MIT" license = "MIT"

View File

@@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cashu.wallet.cli.cli:cli"]}
setuptools.setup( setuptools.setup(
name="cashu", name="cashu",
version="0.9.4", version="0.10.0",
description="Ecash wallet and mint for Bitcoin Lightning", description="Ecash wallet and mint for Bitcoin Lightning",
long_description=long_description, long_description=long_description,
long_description_content_type="text/markdown", long_description_content_type="text/markdown",