mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-20 10:34: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:
|
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
|
||||||
|
|||||||
@@ -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 -------
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user