From 7d79d18ece6477fd64a0fa2c6974d2196b79c338 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 21 Nov 2023 15:17:01 -0300 Subject: [PATCH] add Amount type --- cashu/core/base.py | 36 ++++++++++++++++++++++++++++ cashu/lightning/base.py | 18 ++++++++++---- cashu/lightning/corelightningrest.py | 27 +++++++++++---------- cashu/lightning/fake.py | 20 ++++++++-------- cashu/lightning/lnbits.py | 16 ++++++++----- cashu/lightning/lndrest.py | 29 +++++++++++++++------- cashu/lightning/strike.py | 11 +++++---- cashu/mint/ledger.py | 29 +++++++++++----------- cashu/mint/router_deprecated.py | 2 +- cashu/mint/startup.py | 2 +- cashu/wallet/api/router.py | 4 +++- cashu/wallet/lightning/lightning.py | 3 ++- 12 files changed, 132 insertions(+), 65 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index a6f5112b..9a4e11f0 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1,5 +1,7 @@ import base64 import json +import math +from dataclasses import dataclass from enum import Enum from sqlite3 import Row from typing import Dict, List, Optional, Union @@ -442,6 +444,40 @@ def str(self, amount: int) -> str: raise Exception("Invalid unit") +@dataclass +class Amount: + unit: Unit + amount: int + + def to(self, to_unit: Unit, round: Optional[str] = None): + if self.unit == to_unit: + return self + + if self.unit == Unit.sat: + if to_unit == Unit.msat: + return Amount(to_unit, self.amount * 1000) + else: + raise Exception(f"Cannot convert {self.unit.name} to {to_unit.name}") + elif self.unit == Unit.msat: + if to_unit == Unit.sat: + if round == "up": + return Amount(to_unit, math.ceil(self.amount / 1000)) + elif round == "down": + return Amount(to_unit, math.floor(self.amount / 1000)) + else: + return Amount(to_unit, self.amount // 1000) + else: + raise Exception(f"Cannot convert {self.unit.name} to {to_unit.name}") + else: + return self + + def str(self) -> str: + return self.unit.str(self.amount) + + def __repr__(self): + return self.unit.str(self.amount) + + class Method(Enum): bolt11 = 0 diff --git a/cashu/lightning/base.py b/cashu/lightning/base.py index 5d45e77f..6763ed2c 100644 --- a/cashu/lightning/base.py +++ b/cashu/lightning/base.py @@ -3,6 +3,8 @@ from pydantic import BaseModel +from ..core.base import Amount, Unit + class StatusResponse(BaseModel): error_message: Optional[str] @@ -16,8 +18,8 @@ class InvoiceQuoteResponse(BaseModel): class PaymentQuoteResponse(BaseModel): checking_id: str - amount: int - fee: int + amount: Amount + fee: Amount class InvoiceResponse(BaseModel): @@ -30,14 +32,14 @@ class InvoiceResponse(BaseModel): class PaymentResponse(BaseModel): ok: Optional[bool] = None # True: paid, False: failed, None: pending or unknown checking_id: Optional[str] = None - fee_msat: Optional[int] = None + fee: Optional[Amount] = None preimage: Optional[str] = None error_message: Optional[str] = None class PaymentStatus(BaseModel): paid: Optional[bool] = None - fee_msat: Optional[int] = None + fee: Optional[Amount] = None preimage: Optional[str] = None @property @@ -60,6 +62,12 @@ def __str__(self) -> str: class LightningBackend(ABC): + units: set[Unit] + + def assert_unit_supported(self, unit: Unit): + if unit not in self.units: + raise Unsupported(f"Unit {unit} is not supported") + @abstractmethod def status(self) -> Coroutine[None, None, StatusResponse]: pass @@ -67,7 +75,7 @@ def status(self) -> Coroutine[None, None, StatusResponse]: @abstractmethod def create_invoice( self, - amount: int, + amount: Amount, memo: Optional[str] = None, description_hash: Optional[bytes] = None, ) -> Coroutine[None, None, InvoiceResponse]: diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index a90e68c8..d95478f8 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -1,6 +1,5 @@ import asyncio import json -import math import random from typing import AsyncGenerator, Dict, Optional @@ -11,6 +10,7 @@ ) from loguru import logger +from ..core.base import Amount, Unit from ..core.helpers import fee_reserve from ..core.settings import settings from .base import ( @@ -26,6 +26,8 @@ class CoreLightningRestWallet(LightningBackend): + units = set([Unit.sat, Unit.msat]) + def __init__(self): macaroon = settings.mint_corelightning_rest_macaroon assert macaroon, "missing cln-rest macaroon" @@ -88,15 +90,16 @@ async def status(self) -> StatusResponse: async def create_invoice( self, - amount: int, + amount: Amount, memo: Optional[str] = None, description_hash: Optional[bytes] = None, unhashed_description: Optional[bytes] = None, **kwargs, ) -> InvoiceResponse: + self.assert_unit_supported(amount.unit) label = f"lbl{random.random()}" data: Dict = { - "amount": amount * 1000, + "amount": amount.to(Unit.msat, round="up").amount, "description": memo, "label": label, } @@ -151,7 +154,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=False, checking_id=None, - fee_msat=None, + fee=None, preimage=None, error_message=str(exc), ) @@ -161,7 +164,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=False, checking_id=None, - fee_msat=None, + fee=None, preimage=None, error_message=error_message, ) @@ -186,7 +189,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=False, checking_id=None, - fee_msat=None, + fee=None, preimage=None, error_message=error_message, ) @@ -197,7 +200,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=False, checking_id=None, - fee_msat=None, + fee=None, preimage=None, error_message="payment failed", ) @@ -209,7 +212,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=self.statuses.get(data["status"]), checking_id=checking_id, - fee_msat=fee_msat, + fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, preimage=preimage, error_message=None, ) @@ -254,7 +257,7 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: return PaymentStatus( paid=self.statuses.get(pay["status"]), - fee_msat=fee_msat, + fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, preimage=preimage, ) except Exception as e: @@ -309,6 +312,6 @@ async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: assert invoice_obj.amount_msat, "invoice has no amount." amount_msat = int(invoice_obj.amount_msat) fees_msat = fee_reserve(amount_msat) - fee_sat = math.ceil(fees_msat / 1000) - amount_sat = math.ceil(amount_msat / 1000) - return PaymentQuoteResponse(checking_id="", fee=fee_sat, amount=amount_sat) + fees = Amount(unit=Unit.msat, amount=fees_msat) + amount = Amount(unit=Unit.msat, amount=amount_msat) + return PaymentQuoteResponse(checking_id="", fee=fees, amount=amount) diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index d12e34d0..94a8062b 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -1,6 +1,5 @@ import asyncio import hashlib -import math import random from datetime import datetime from os import urandom @@ -15,6 +14,7 @@ encode, ) +from ..core.base import Amount, Unit from ..core.helpers import fee_reserve from ..core.settings import settings from .base import ( @@ -28,8 +28,7 @@ class FakeWallet(LightningBackend): - """https://github.com/lnbits/lnbits""" - + units = set([Unit.sat, Unit.msat]) queue: asyncio.Queue[Bolt11] = asyncio.Queue(0) payment_secrets: Dict[str, str] = dict() paid_invoices: Set[str] = set() @@ -47,13 +46,14 @@ async def status(self) -> StatusResponse: async def create_invoice( self, - amount: int, + amount: Amount, memo: Optional[str] = None, description_hash: Optional[bytes] = None, unhashed_description: Optional[bytes] = None, expiry: Optional[int] = None, payment_secret: Optional[bytes] = None, ) -> InvoiceResponse: + self.assert_unit_supported(amount.unit) tags = Tags() if description_hash: @@ -83,7 +83,7 @@ async def create_invoice( bolt11 = Bolt11( currency="bc", - amount_msat=MilliSatoshi(amount * 1000), + amount_msat=MilliSatoshi(amount.to(Unit.msat, round="up").amount), date=int(datetime.now().timestamp()), tags=tags, ) @@ -94,7 +94,7 @@ async def create_invoice( ok=True, checking_id=payment_hash, payment_request=payment_request ) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit: int) -> PaymentResponse: invoice = decode(bolt11) if settings.fakewallet_delay_payment: @@ -106,7 +106,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=True, checking_id=invoice.payment_hash, - fee_msat=0, + fee=Amount(unit=Unit.msat, amount=0), preimage=self.payment_secrets.get(invoice.payment_hash) or "0" * 64, ) else: @@ -140,6 +140,6 @@ async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: assert invoice_obj.amount_msat, "invoice has no amount." amount_msat = int(invoice_obj.amount_msat) fees_msat = fee_reserve(amount_msat) - fee_sat = math.ceil(fees_msat / 1000) - amount_sat = math.ceil(amount_msat / 1000) - return PaymentQuoteResponse(checking_id="", fee=fee_sat, amount=amount_sat) + fees = Amount(unit=Unit.msat, amount=fees_msat) + amount = Amount(unit=Unit.msat, amount=amount_msat) + return PaymentQuoteResponse(checking_id="", fee=fees, amount=amount) diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index 20b07e69..f97f3fbc 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -1,5 +1,4 @@ # type: ignore -import math from typing import Optional import httpx @@ -7,6 +6,7 @@ decode, ) +from ..core.base import Amount, Unit from ..core.helpers import fee_reserve from ..core.settings import settings from .base import ( @@ -22,6 +22,8 @@ class LNbitsWallet(LightningBackend): """https://github.com/lnbits/lnbits""" + units = set([Unit.sat]) + def __init__(self): self.endpoint = settings.mint_lnbits_endpoint self.client = httpx.AsyncClient( @@ -57,12 +59,14 @@ async def status(self) -> StatusResponse: async def create_invoice( self, - amount: int, + amount: Amount, memo: Optional[str] = None, description_hash: Optional[bytes] = None, unhashed_description: Optional[bytes] = None, ) -> InvoiceResponse: - data = {"out": False, "amount": amount} + self.assert_unit_supported(amount.unit) + + data = {"out": False, "amount": amount.to(Unit.sat).amount} if description_hash: data["description_hash"] = description_hash.hex() if unhashed_description: @@ -153,6 +157,6 @@ async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: assert invoice_obj.amount_msat, "invoice has no amount." amount_msat = int(invoice_obj.amount_msat) fees_msat = fee_reserve(amount_msat) - fee_sat = math.ceil(fees_msat / 1000) - amount_sat = math.ceil(amount_msat / 1000) - return PaymentQuoteResponse(checking_id="", fee=fee_sat, amount=amount_sat) + fees = Amount(unit=Unit.msat, amount=fees_msat) + amount = Amount(unit=Unit.msat, amount=amount_msat) + return PaymentQuoteResponse(checking_id="", fee=fees, amount=amount) diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index 37c7b96f..5a93e209 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -2,7 +2,6 @@ import base64 import hashlib import json -import math from typing import AsyncGenerator, Dict, Optional import httpx @@ -11,6 +10,7 @@ ) from loguru import logger +from ..core.base import Amount, Unit from ..core.helpers import fee_reserve from ..core.settings import settings from .base import ( @@ -27,6 +27,8 @@ class LndRestWallet(LightningBackend): """https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" + units = set([Unit.sat, Unit.msat]) + def __init__(self): endpoint = settings.mint_lnd_rest_endpoint cert = settings.mint_lnd_rest_cert @@ -87,13 +89,18 @@ async def status(self) -> StatusResponse: async def create_invoice( self, - amount: int, + amount: Amount, memo: Optional[str] = None, description_hash: Optional[bytes] = None, unhashed_description: Optional[bytes] = None, **kwargs, ) -> InvoiceResponse: - data: Dict = {"value": amount, "private": True, "memo": memo or ""} + self.assert_unit_supported(amount.unit) + data: Dict = { + "value": amount.to(Unit.msat, round="up").amount, + "private": True, + "memo": memo or "", + } if kwargs.get("expiry"): data["expiry"] = kwargs["expiry"] if description_hash: @@ -148,7 +155,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=False, checking_id=None, - fee_msat=None, + fee=None, preimage=None, error_message=error_message, ) @@ -160,7 +167,7 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse return PaymentResponse( ok=True, checking_id=checking_id, - fee_msat=fee_msat, + fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, preimage=preimage, error_message=None, ) @@ -213,7 +220,11 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: if payment is not None and payment.get("status"): return PaymentStatus( paid=statuses[payment["status"]], - fee_msat=payment.get("fee_msat"), + fee=( + Amount(unit=Unit.msat, amount=payment.get("fee_msat")) + if payment.get("fee_msat") + else None + ), preimage=payment.get("payment_preimage"), ) else: @@ -250,6 +261,6 @@ async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: assert invoice_obj.amount_msat, "invoice has no amount." amount_msat = int(invoice_obj.amount_msat) fees_msat = fee_reserve(amount_msat) - fee_sat = math.ceil(fees_msat / 1000) - amount_sat = math.ceil(amount_msat / 1000) - return PaymentQuoteResponse(checking_id="", fee=fee_sat, amount=amount_sat) + fees = Amount(unit=Unit.msat, amount=fees_msat) + amount = Amount(unit=Unit.msat, amount=amount_msat) + return PaymentQuoteResponse(checking_id="", fee=fees, amount=amount) diff --git a/cashu/lightning/strike.py b/cashu/lightning/strike.py index 2707030a..db8f8918 100644 --- a/cashu/lightning/strike.py +++ b/cashu/lightning/strike.py @@ -4,7 +4,7 @@ import httpx -from ..core.base import Method, Unit +from ..core.base import Amount, Unit from ..core.settings import settings from .base import ( InvoiceResponse, @@ -19,8 +19,7 @@ class StrikeUSDWallet(LightningBackend): """https://github.com/lnbits/lnbits""" - method = Method.bolt11 - unit = Unit.usd + units = [Unit.usd] def __init__(self): self.endpoint = "https://api.strike.me" @@ -60,11 +59,13 @@ async def status(self) -> StatusResponse: async def create_invoice( self, - amount: int, + amount: Amount, memo: Optional[str] = None, description_hash: Optional[bytes] = None, unhashed_description: Optional[bytes] = None, ) -> InvoiceResponse: + self.assert_unit_supported(amount.unit) + data: Dict = {"out": False, "amount": amount} if description_hash: data["description_hash"] = description_hash.hex() @@ -130,7 +131,7 @@ async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: amount_cent = int(float(data.get("amount").get("amount")) * 100) quote = PaymentQuoteResponse( - amount=amount_cent, id=data.get("paymentQuoteId"), fee=0 + amount=amount_cent, id=data.get("paymentQuoteId"), fee=Amount(Unit.msat, 0) ) return quote diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 69332846..37b907dd 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1,6 +1,5 @@ import asyncio import copy -import math from typing import Dict, List, Mapping, Optional, Set, Tuple import bolt11 @@ -8,6 +7,7 @@ from ..core.base import ( DLEQ, + Amount, BlindedMessage, BlindedSignature, MeltQuote, @@ -287,12 +287,11 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: raise NotAllowedError("Mint does not allow minting new tokens.") unit = Unit[quote_request.unit] method = Method["bolt11"] - requested_amount_sat = quote_request.amount logger.trace(f"requesting invoice for {unit.str(quote_request.amount)}") invoice_response: InvoiceResponse = await self.backends[method][ unit - ].create_invoice(requested_amount_sat) + ].create_invoice(Amount(unit=unit, amount=quote_request.amount)) logger.trace( f"got invoice {invoice_response.payment_request} with check id" f" {invoice_response.checking_id}" @@ -390,16 +389,15 @@ async def melt_quote( unit ].get_payment_quote(melt_quote.request) - # NOTE: We do not store the fee reserve in the database. quote = MeltQuote( quote=random_hash(), method="bolt11", # TODO: remove unnecessary fields request=melt_quote.request, # TODO: remove unnecessary fields checking_id=payment_quote.checking_id, unit=melt_quote.unit, - amount=payment_quote.amount, + amount=payment_quote.amount.to(unit).amount, paid=False, - fee_reserve=payment_quote.fee, + fee_reserve=payment_quote.fee.to(unit).amount, ) await self.crud.store_melt_quote(quote=quote, db=self.db) return PostMeltQuoteResponse( @@ -464,9 +462,9 @@ async def melt( try: # verify amounts from bolt11 invoice - invoice_obj = bolt11.decode(bolt11_request) - assert invoice_obj.amount_msat, "invoice has no amount." - invoice_amount = math.ceil(invoice_obj.amount_msat / 1000) + # invoice_obj = bolt11.decode(bolt11_request) + # assert invoice_obj.amount_msat, "invoice has no amount." + # invoice_amount = math.ceil(invoice_obj.amount_msat / 1000) # first we check if there is a mint quote with the same payment request # so that we can handle the transaction internally without lightning @@ -476,7 +474,10 @@ async def melt( ) if mint_quote: # we settle the transaction internally - assert mint_quote.amount == invoice_amount, "amounts do not match" + # assert Amount(unit, mint_quote.amount).to() == invoice_amount, "amounts do not match" + assert ( + bolt11_request == mint_quote.request + ), "bolt11 requests do not match" assert mint_quote.unit == melt_quote.unit, "units do not match" assert mint_quote.method == melt_quote.method, "methods do not match" assert not mint_quote.paid, "mint quote already paid" @@ -500,12 +501,12 @@ async def melt( logger.debug( f"Melt status: {payment.ok}: preimage: {payment.preimage}," - f" fee: {unit.str(payment.fee_msat) if payment.fee_msat else 0}" + f" fee: {payment.fee.str() if payment.fee else 0}" ) if not payment.ok: raise LightningError("Lightning payment unsuccessful.") - if payment.fee_msat: - fees_paid = math.ceil(payment.fee_msat / 1000) + if payment.fee: + fees_paid = payment.fee.to(to_unit=unit, round="up").amount if payment.preimage: payment_proof = payment.preimage @@ -520,7 +521,7 @@ async def melt( if outputs and fees_paid is not None: return_promises = await self._generate_change_promises( input_amount=total_provided, - output_amount=invoice_amount, + output_amount=melt_quote.amount, output_fee_paid=fees_paid, outputs=outputs, keyset=self.keysets[outputs[0].id], diff --git a/cashu/mint/router_deprecated.py b/cashu/mint/router_deprecated.py index fb98260a..43b2e5f4 100644 --- a/cashu/mint/router_deprecated.py +++ b/cashu/mint/router_deprecated.py @@ -235,7 +235,7 @@ async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse: payment_quote = await ledger.backends[method][unit].get_payment_quote(payload.pr) fees_sat = payment_quote.fee logger.trace(f"< POST /checkfees: {fees_sat}") - return CheckFeesResponse(fee=fees_sat) + return CheckFeesResponse(fee=fees_sat.amount) @router_deprecated.post( diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index cba27705..8bac0e38 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -28,7 +28,7 @@ # } backends = { - Method.bolt11: {Unit.sat: lightning_backend}, + Method.bolt11: {Unit.sat: lightning_backend, Unit.msat: lightning_backend}, } ledger = Ledger( db=Database("mint", settings.mint_database), diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index f7e1fc9c..b6501277 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -92,7 +92,9 @@ async def pay( if mint: wallet = await mint_wallet(mint) payment_response = await wallet.pay_invoice(bolt11) - return payment_response + ret = PaymentResponse(**payment_response.dict()) + ret.fee = None # TODO: we can't return an Amount object, overwriting + return ret @router.get( diff --git a/cashu/wallet/lightning/lightning.py b/cashu/wallet/lightning/lightning.py index 8c983569..3b687f39 100644 --- a/cashu/wallet/lightning/lightning.py +++ b/cashu/wallet/lightning/lightning.py @@ -1,5 +1,6 @@ import bolt11 +from ...core.base import Amount, Unit from ...core.helpers import sum_promises from ...core.settings import settings from ...lightning.base import ( @@ -75,7 +76,7 @@ async def pay_invoice(self, pr: str) -> PaymentResponse: ok=True, checking_id=invoice_obj.payment_hash, preimage=resp.proof, - fee_msat=fees_paid_sat * 1000, + fee=Amount(Unit.msat, fees_paid_sat), ) except Exception as e: print("Exception:", e)