mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-20 02:24:20 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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 -------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 <address> in proof.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}."
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "cashu"
|
||||
version = "0.9.4"
|
||||
version = "0.10.0"
|
||||
description = "Ecash wallet and mint."
|
||||
authors = ["calle <callebtc@protonmail.com>"]
|
||||
license = "MIT"
|
||||
|
||||
2
setup.py
2
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",
|
||||
|
||||
Reference in New Issue
Block a user