diff --git a/cashu/core/base.py b/cashu/core/base.py index 88c83552..48ebbb63 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -279,6 +279,7 @@ def from_row(cls, row: Row): quote=row["quote"], method=row["method"], request=row["request"], + expiry=row["expiry"], checking_id=row["checking_id"], unit=row["unit"], amount=row["amount"], diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 205c3099..1c3aa666 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -63,6 +63,19 @@ class MintSettings(CashuSettings): mint_input_fee_ppk: int = Field(default=0) +class GatewaySettings(CashuSettings): + gateway_private_key: str = Field(default=None) + gateway_listen_host: str = Field(default="127.0.0.1") + gateway_listen_port: int = Field(default=3838) + gateway_database: str = Field(default="data/gateway") + + gateway_backend_bolt11_sat: str = Field(default="") + gateway_bolt11_sat_fee_ppm: int = Field(default=1000) + gateway_bolt11_sat_base_fee: int = Field(default=2) + + gateway_mint_urls: List[str] = Field(default=[]) + + class MintBackends(MintSettings): mint_lightning_backend: str = Field(default="") # deprecated mint_backend_bolt11_sat: str = Field(default="") @@ -172,6 +185,7 @@ class WalletSettings(CashuSettings): locktime_delta_seconds: int = Field(default=86400) # 1 day proofs_batch_size: int = Field(default=1000) + wallet_gateways: List[str] = Field(default=[]) wallet_target_amount_count: int = Field(default=3) @@ -201,6 +215,7 @@ class Settings( MintSettings, MintInformation, WalletSettings, + GatewaySettings, CashuSettings, ): version: str = Field(default=VERSION) diff --git a/cashu/gateway/__main__.py b/cashu/gateway/__main__.py new file mode 100644 index 00000000..5d6a8109 --- /dev/null +++ b/cashu/gateway/__main__.py @@ -0,0 +1,3 @@ +from .main import main + +main() diff --git a/cashu/gateway/app.py b/cashu/gateway/app.py new file mode 100644 index 00000000..c531cba9 --- /dev/null +++ b/cashu/gateway/app.py @@ -0,0 +1,91 @@ +import sys +from traceback import print_exception + +from fastapi import FastAPI, status +from fastapi.responses import JSONResponse +from loguru import logger +from starlette.requests import Request + +from ..core.errors import CashuError +from ..core.logging import configure_logger +from ..core.settings import settings +from .router import router +from .startup import start_gateway_init + +if settings.debug_profiling: + pass + +if settings.mint_rate_limit: + pass + +# this errors with the tests but is the appropriate way to handle startup and shutdown +# until then, we use @app.on_event("startup") +# @asynccontextmanager +# async def lifespan(app: FastAPI): +# # startup routines here +# await start_mint_init() +# yield +# # shutdown routines here + + +def create_app(config_object="core.settings") -> FastAPI: + configure_logger() + + app = FastAPI( + title="Nutshell Cashu Mint", + description="Ecash wallet and mint based on the Cashu protocol.", + version=settings.version, + license_info={ + "name": "MIT License", + "url": "https://raw.githubusercontent.com/cashubtc/cashu/main/LICENSE", + }, + ) + + return app + + +app = create_app() + + +@app.middleware("http") +async def catch_exceptions(request: Request, call_next): + CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Credentials": "true", + } + try: + return await call_next(request) + except Exception as e: + try: + err_message = str(e) + except Exception: + err_message = e.args[0] if e.args else "Unknown error" + + if isinstance(e, CashuError) or isinstance(e.args[0], CashuError): + logger.error(f"CashuError: {err_message}") + code = e.code if isinstance(e, CashuError) else e.args[0].code + # return with cors headers + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": err_message, "code": code}, + headers=CORS_HEADERS, + ) + logger.error(f"Exception: {err_message}") + if settings.debug: + print_exception(*sys.exc_info()) + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": err_message, "code": 0}, + headers=CORS_HEADERS, + ) + + +# Add exception handlers +app.include_router(router=router, tags=["Gateway"]) + + +@app.on_event("startup") +async def startup_gateway(): + await start_gateway_init() diff --git a/cashu/gateway/crud.py b/cashu/gateway/crud.py new file mode 100644 index 00000000..9f174a8a --- /dev/null +++ b/cashu/gateway/crud.py @@ -0,0 +1,263 @@ +from abc import ABC, abstractmethod +from typing import Any, List, Optional, Tuple + +from ..core.base import ( + MeltQuote, + MintQuote, +) +from ..core.db import ( + Connection, + Database, + table_with_schema, + timestamp_from_seconds, +) + + +class GatewayCrud(ABC): + @abstractmethod + async def store_melt_quote( + self, + *, + mint: str, + quote: MeltQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + ... + + @abstractmethod + async def get_melt_quote( + self, + *, + quote_id: str, + db: Database, + checking_id: Optional[str] = None, + conn: Optional[Connection] = None, + ) -> Tuple[str, MeltQuote]: + ... + + @abstractmethod + async def update_melt_quote( + self, + *, + quote: MeltQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + ... + + @abstractmethod + async def store_mint_quote( + self, + *, + mint: str, + quote: MeltQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + ... + + @abstractmethod + async def get_mint_quote( + self, + *, + quote_id: str, + db: Database, + checking_id: Optional[str] = None, + conn: Optional[Connection] = None, + ) -> Tuple[str, MeltQuote]: + ... + + @abstractmethod + async def update_mint_quote( + self, + *, + quote: MeltQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + ... + + +class GatewayCrudSqlite(GatewayCrud): + async def store_melt_quote( + self, + *, + mint: str, + quote: MeltQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + await (conn or db).execute( + f""" + INSERT INTO {table_with_schema(db, 'melt_quotes')} + (mint, quote, method, request, expiry, checking_id, unit, amount, fee_reserve, paid, created_time, paid_time, fee_paid, proof) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + mint, + quote.quote, + quote.method, + quote.request, + quote.expiry, + quote.checking_id, + quote.unit, + quote.amount, + quote.fee_reserve or 0, + quote.paid, + timestamp_from_seconds(db, quote.created_time), + timestamp_from_seconds(db, quote.paid_time), + quote.fee_paid, + quote.proof, + ), + ) + + async def get_melt_quote( + self, + *, + quote_id: str, + db: Database, + checking_id: Optional[str] = None, + request: Optional[str] = None, + conn: Optional[Connection] = None, + ) -> Tuple[str, MeltQuote]: + clauses = [] + values: List[Any] = [] + if quote_id: + clauses.append("quote = ?") + values.append(quote_id) + if checking_id: + clauses.append("checking_id = ?") + values.append(checking_id) + if request: + clauses.append("request = ?") + values.append(request) + where = "" + if clauses: + where = f"WHERE {' AND '.join(clauses)}" + row = await (conn or db).fetchone( + f""" + SELECT * from {table_with_schema(db, 'melt_quotes')} + {where} + """, + tuple(values), + ) + if row is None: + raise ValueError("Quote not found") + + mint = row["mint"] + return (mint, MeltQuote.from_row(row)) + + async def update_melt_quote( + self, + *, + quote: MeltQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + await (conn or db).execute( + f"UPDATE {table_with_schema(db, 'melt_quotes')} SET paid = ?, fee_paid = ?," + " paid_time = ?, proof = ? WHERE quote = ?", + ( + quote.paid, + quote.fee_paid, + timestamp_from_seconds(db, quote.paid_time), + quote.proof, + quote.quote, + ), + ) + + # class MintQuote(BaseModel): + # quote: str + # method: str + # request: str + # checking_id: str + # unit: str + # amount: int + # paid: bool + # issued: bool + # created_time: Union[int, None] = None + # paid_time: Union[int, None] = None + # expiry: Optional[int] = None + + async def store_mint_quote( + self, + *, + mint: str, + quote: MintQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + await (conn or db).execute( + f""" + INSERT INTO {table_with_schema(db, 'mint_quotes')} + (mint, quote, method, unit, amount, request, checking_id, paid, created_time, paid_time, expiry) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + mint, + quote.quote, + quote.method, + quote.unit, + quote.amount, + quote.request, + quote.checking_id, + quote.paid, + timestamp_from_seconds(db, quote.created_time), + timestamp_from_seconds(db, quote.paid_time), + quote.expiry, + ), + ) + + async def get_mint_quote( + self, + *, + quote_id: str, + db: Database, + checking_id: Optional[str] = None, + request: Optional[str] = None, + conn: Optional[Connection] = None, + ) -> Tuple[str, MintQuote]: + clauses = [] + values: List[Any] = [] + if quote_id: + clauses.append("quote = ?") + values.append(quote_id) + if checking_id: + clauses.append("checking_id = ?") + values.append(checking_id) + if request: + clauses.append("request = ?") + values.append(request) + where = "" + if clauses: + where = f"WHERE {' AND '.join(clauses)}" + row = await (conn or db).fetchone( + f""" + SELECT * from {table_with_schema(db, 'mint_quotes')} + {where} + """, + tuple(values), + ) + if row is None: + raise ValueError("Quote not found") + + mint = row["mint"] + return (mint, MintQuote.from_row(row)) + + async def update_mint_quote( + self, + *, + quote: MintQuote, + db: Database, + conn: Optional[Connection] = None, + ) -> None: + await (conn or db).execute( + f"UPDATE {table_with_schema(db, 'mint_quotes')} SET paid = ?," + " paid_time = ? WHERE quote = ?", + ( + quote.paid, + timestamp_from_seconds(db, quote.paid_time), + quote.quote, + ), + ) diff --git a/cashu/gateway/gateway.py b/cashu/gateway/gateway.py new file mode 100644 index 00000000..dff3272b --- /dev/null +++ b/cashu/gateway/gateway.py @@ -0,0 +1,454 @@ +import asyncio +import os +import time +from typing import Dict, List, Mapping + +import bolt11 +from loguru import logger + +from cashu.core.models import PostMeltQuoteRequest + +from ..core.base import ( + Amount, + HTLCWitness, + MeltQuote, + Method, + Proof, + Unit, +) +from ..core.crypto.keys import random_hash +from ..core.db import Database +from ..core.errors import LightningError, TransactionError +from ..core.htlc import HTLCSecret +from ..core.secret import Secret, SecretKind +from ..core.settings import settings +from ..lightning.base import ( + LightningBackend, +) +from ..mint.ledger import Ledger +from ..wallet.wallet import Wallet +from .crud import GatewayCrudSqlite +from .models import ( + GatewayMeltQuoteRequest, + GatewayMeltQuoteResponse, + GatewayMeltResponse, + GatewayMintQuoteRequest, + GatewayMintQuoteResponse, + GatewayMintResponse, +) + +# This makes sure that we require ecash htlc lock time and +# invoice lock time at least LOCKTIME_SAFETY seconds apart +LOCKTIME_SAFETY = 60 # 1 minute + + +class Gateway(Ledger): + locks: Dict[str, asyncio.Lock] = {} # holds mutex locks + backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} + db: Database + wallets: Dict[str, Wallet] = {} + + def __init__( + self, + db: Database, + seed: str, + backends: Mapping[Method, Mapping[Unit, LightningBackend]], + crud=GatewayCrudSqlite(), + ): + self.db = db + self.seed = seed + self.backends = backends + self.gwcrud = crud + + async def init_wallets(self): + for mint in settings.gateway_mint_urls: + logger.info(f"Loading wallet for mint: {mint}") + self.wallets[mint] = await Wallet.with_db( + mint, + db=os.path.join(settings.cashu_dir, "gateway"), + name="gateway", + ) + await self.wallets[mint].load_proofs() + await self.wallets[mint].load_mint() + + # ------- STARTUP ------- + + async def startup_gateway(self): + await self._startup_gateway() + # await self._check_pending_proofs_and_melt_quotes() + + async def _startup_gateway(self): + for method in self.backends: + for unit in self.backends[method]: + logger.info( + f"Using {self.backends[method][unit].__class__.__name__} backend for" + f" method: '{method.name}' and unit: '{unit.name}'" + ) + status = await self.backends[method][unit].status() + if status.error_message: + logger.warning( + "The backend for" + f" {self.backends[method][unit].__class__.__name__} isn't" + f" working properly: '{status.error_message}'", + RuntimeWarning, + ) + logger.info(f"Backend balance: {status.balance} {unit.name}") + logger.info(f"Data dir: {settings.cashu_dir}") + + async def gateway_mint_quote( + self, mint_quote_request: GatewayMintQuoteRequest + ) -> GatewayMintQuoteResponse: + if mint_quote_request.mint not in self.wallets.keys(): + raise TransactionError( + f"mint does not match gateway mint: {self.wallets.keys()}" + ) + mint = mint_quote_request.mint + # TODO: check if the mint is in the wallet + assert mint in self.wallets.keys() + + amount = mint_quote_request.amount + unit = Unit[mint_quote_request.unit] + pament_hash = mint_quote_request.payment_hash + + payment_quote = await self.backends[Method.bolt11][ + Unit.sat + ].create_hold_invoice(amount=Amount(unit, amount), payment_hash=pament_hash) + if ( + not payment_quote.ok + or not payment_quote.checking_id + or not payment_quote.payment_request + ): + raise TransactionError("failed to create hold invoice") + invoice = bolt11.decode(pr=payment_quote.payment_request) + amount_msat = invoice.amount_msat + if amount_msat is None: + raise Exception("amount_msat is None") + + return GatewayMintQuoteResponse( + quote=payment_quote.checking_id, + request=payment_quote.payment_request, + paid=False, + expiry=invoice.expiry, + ) + + async def gateway_get_mint_quote( + self, quote_id: str, check_quote_with_backend: bool = False + ) -> GatewayMintQuoteResponse: + mint, mint_quote = await self.gwcrud.get_mint_quote( + quote_id=quote_id, db=self.db + ) + if mint not in self.wallets.keys(): + raise TransactionError("mint not found") + if not mint_quote: + raise TransactionError("quote not found") + if not mint_quote.expiry: + raise TransactionError("quote does not have expiry") + if check_quote_with_backend and not mint_quote.paid: + unit = Unit[mint_quote.unit] + if unit not in self.backends[Method.bolt11]: + raise Exception("unit not supported by backend") + method = Method.bolt11 + # get the backend for the unit + payment_quote = await self.backends[method][unit].get_payment_status( + mint_quote.checking_id + ) + if payment_quote.paid: + mint_quote.paid = True + mint_quote.paid_time = int(time.time()) + # await self.gwcrud.update_mint_quote(quote=mint_quote, db=self.db) + + return GatewayMintQuoteResponse( + quote=mint_quote.quote, + request=mint_quote.request, + paid=mint_quote.paid, + expiry=mint_quote.expiry, + ) + + async def gateway_mint( + self, + *, + quote: str, + ) -> GatewayMintResponse: + try: + mint, mint_quote = await self.gwcrud.get_mint_quote( + quote_id=quote, db=self.db + ) + except ValueError as e: + raise TransactionError(str(e)) + if not mint_quote: + raise TransactionError("quote not found") + if mint_quote.paid: + raise TransactionError("quote is already paid") + if not mint_quote.expiry or mint_quote.expiry < int(time.time()): + raise TransactionError("quote expired") + unit = Unit[mint_quote.unit] + if unit not in self.backends[Method.bolt11]: + raise Exception("unit not supported by backend") + # method = Method.bolt11 + if mint not in self.wallets: + raise TransactionError("mint not found") + # wallet = self.wallets[mint] + + # get the backend for the unit + invoice = bolt11.decode(mint_quote.request) + if invoice.amount_msat is None: + raise TransactionError("invoice has no amount") + # NOTE: this check will only work for sat quotes + if invoice.amount_msat // 1000 > mint_quote.amount: + raise TransactionError("invoice amount is greater than quote amount") + + # pay the backend + logger.debug(f"Lightning: pay invoice {mint_quote.request}") + + # payment = await self.backends[method][unit].pay_invoice( + # mint_quote, mint_quote.fee_reserve * 1000 + # ) + return GatewayMintResponse(inputs=[]) + + async def gateway_melt_quote( + self, melt_quote_request: GatewayMeltQuoteRequest + ) -> GatewayMeltQuoteResponse: + if melt_quote_request.mint not in self.wallets.keys(): + raise TransactionError( + f"mint does not match gateway mint: {self.wallets.keys()}" + ) + mint = melt_quote_request.mint + pubkey = await self.wallets[mint].create_p2pk_pubkey() + request = melt_quote_request.request + invoice = bolt11.decode(pr=request) + amount_msat = bolt11.decode(pr=request).amount_msat + if amount_msat is None: + raise Exception("amount_msat is None") + amount = amount_msat // 1000 + + # add fees to the amount + if settings.gateway_bolt11_sat_fee_ppm: + amount += amount * settings.gateway_bolt11_sat_fee_ppm // 1000000 + if settings.gateway_bolt11_sat_base_fee: + amount += settings.gateway_bolt11_sat_base_fee + + unit = Unit[melt_quote_request.unit] + if unit not in self.backends[Method.bolt11]: + raise Exception("unit not supported by backend") + method = Method.bolt11 + + # not internal, get payment quote by backend + payment_quote = await self.backends[method][unit].get_payment_quote( + PostMeltQuoteRequest(unit=unit.name, request=request) + ) + if not payment_quote.checking_id: + raise TransactionError("quote has no checking id") + # make sure the backend returned the amount with a correct unit + if not payment_quote.amount.unit == unit: + raise TransactionError("payment quote amount units do not match") + # fee from the backend must be in the same unit as the amount + if not payment_quote.fee.unit == unit: + raise TransactionError("payment quote fee units do not match") + + if not invoice.date or not invoice.expiry: + raise TransactionError("invoice does not have date or expiry") + + # check if invoice is already paid + payment_quote_check = await self.backends[method][unit].get_payment_status( + payment_quote.checking_id + ) + if payment_quote_check.paid: + raise TransactionError("invoice is already paid") + + invoice_expiry = invoice.date + invoice.expiry + + melt_quote = MeltQuote( + quote=random_hash(), + method=method.name, + request=request, + checking_id=payment_quote.checking_id, + unit=unit.name, + amount=amount, + fee_reserve=payment_quote.fee.amount, + paid=False, + created_time=0, + paid_time=0, + fee_paid=0, + expiry=invoice_expiry + LOCKTIME_SAFETY, + ) + + await self.gwcrud.store_melt_quote(mint=mint, quote=melt_quote, db=self.db) + assert melt_quote.expiry + return GatewayMeltQuoteResponse( + pubkey=pubkey, + quote=melt_quote.quote, + amount=melt_quote.amount, + expiry=melt_quote.expiry, + paid=melt_quote.paid, + ) + + async def gateway_get_melt_quote( + self, quote_id: str, check_quote_with_backend: bool = False + ) -> GatewayMeltQuoteResponse: + mint, melt_quote = await self.gwcrud.get_melt_quote( + quote_id=quote_id, db=self.db + ) + if mint not in self.wallets.keys(): + raise TransactionError("mint not found") + pubkey = await self.wallets[mint].create_p2pk_pubkey() + if not melt_quote: + raise TransactionError("quote not found") + if not melt_quote.expiry: + raise TransactionError("quote does not have expiry") + if check_quote_with_backend and not melt_quote.paid: + unit = Unit[melt_quote.unit] + if unit not in self.backends[Method.bolt11]: + raise Exception("unit not supported by backend") + method = Method.bolt11 + # get the backend for the unit + payment_quote = await self.backends[method][unit].get_payment_status( + melt_quote.checking_id + ) + if payment_quote.paid: + melt_quote.paid = True + melt_quote.paid_time = int(time.time()) + await self.gwcrud.update_melt_quote(quote=melt_quote, db=self.db) + + return GatewayMeltQuoteResponse( + pubkey=pubkey, + quote=melt_quote.quote, + amount=melt_quote.amount, + expiry=melt_quote.expiry, + paid=melt_quote.paid, + ) + + def _check_proofs(self, wallet: Wallet, proofs: List[Proof]): + if not proofs: + raise TransactionError("no proofs") + # make sure there are no duplicate proofs + if len(proofs) != len(set(p.secret for p in proofs)): + raise TransactionError("duplicate proofs") + if not all([self._verify_secret_criteria(p) for p in proofs]): + raise TransactionError("secrets do not match criteria.") + for proof in proofs: + # check if proof keysets are from the gateway's wallet's mint + if proof.id not in wallet.keysets: + raise TransactionError("proof keysets not valid") + + def _verify_htlc( + self, + proof: Proof, + hashlock: str, + pubkey: str, + expiry: int, + ): + secret = Secret.deserialize(proof.secret) + if SecretKind(secret.kind) != SecretKind.HTLC: + raise TransactionError("proof secret kind is not HTLC") + htlc_secret = HTLCSecret.from_secret(secret) + if htlc_secret.data != hashlock: + raise TransactionError("proof secret data does not match hashlock") + hashlock_pubkeys = htlc_secret.tags.get_tag_all("pubkeys") + if not hashlock_pubkeys: + raise TransactionError("proof secret does not have hashlock pubkeys") + is_valid = False + for htlc_pubkey in hashlock_pubkeys: + if htlc_pubkey == pubkey: + is_valid = True + break + if not is_valid: + raise TransactionError("proof secret pubkey does not match hashlock pubkey") + + locktime = htlc_secret.tags.get_tag("locktime") + if locktime: + locktime = int(locktime) + if locktime < expiry: + raise TransactionError("proof secret locktime is not valid") + else: + logger.error(f"locktime: {locktime}") + raise TransactionError("no locktime in proof secret") + + async def gateway_melt( + self, + *, + proofs: List[Proof], + quote: str, + ) -> GatewayMeltResponse: + try: + mint, melt_quote = await self.gwcrud.get_melt_quote( + quote_id=quote, db=self.db + ) + except ValueError as e: + raise TransactionError(str(e)) + if not melt_quote: + raise TransactionError("quote not found") + if melt_quote.paid: + raise TransactionError("quote is already paid") + if melt_quote.amount != sum(p.amount for p in proofs): + raise TransactionError("proofs amount does not match quote") + if not melt_quote.expiry or melt_quote.expiry < int(time.time()): + raise TransactionError("quote expired") + unit = Unit[melt_quote.unit] + if unit not in self.backends[Method.bolt11]: + raise Exception("unit not supported by backend") + method = Method.bolt11 + if mint not in self.wallets: + raise TransactionError("mint not found") + wallet = self.wallets[mint] + pubkey = await wallet.create_p2pk_pubkey() + # get the backend for the unit + invoice = bolt11.decode(melt_quote.request) + if invoice.amount_msat is None: + raise TransactionError("invoice has no amount") + # NOTE: this check will only work for sat quotes + if invoice.amount_msat // 1000 > melt_quote.amount: + raise TransactionError("invoice amount is greater than quote amount") + + # check proofs + self._check_proofs(wallet, proofs) + # check if signatures of proofs are valid using DLEQ proofs + wallet.verify_proofs_dleq(proofs=proofs) + # check if the HTLCs are valid + for proof in proofs: + self._verify_htlc( + proof=proof, + hashlock=invoice.payment_hash, + pubkey=pubkey, + expiry=melt_quote.expiry, + ) + + # proofs are ok + + # pay the backend + logger.debug(f"Lightning: pay invoice {melt_quote.request}") + # hack: we need to set the amount in the melt_quote to the amount in the invoice + # because it includes fees, the backend thinks it's an mpp payment (which it is not) + melt_quote.amount = invoice.amount_msat // 1000 + payment = await self.backends[method][unit].pay_invoice( + melt_quote, melt_quote.fee_reserve * 1000 + ) + logger.debug( + f"Lightning payment – Ok: {payment.ok}: preimage: {payment.preimage}," + f" fee: {payment.fee.str() if payment.fee is not None else 'None'}" + ) + if not payment.ok: + raise LightningError( + f"Lightning payment unsuccessful. {payment.error_message}" + ) + if payment.fee: + melt_quote.fee_paid = payment.fee.to(to_unit=unit, round="up").amount + if payment.preimage: + melt_quote.proof = payment.preimage + # set quote as paid + melt_quote.paid = True + melt_quote.paid_time = int(time.time()) + await self.gwcrud.update_melt_quote(quote=melt_quote, db=self.db) + + # redeem proofs + signatures = await wallet.sign_p2pk_proofs(proofs) + for p, s in zip(proofs, signatures): + p.witness = HTLCWitness(preimage=payment.preimage, signature=s).json() + + print(f"Balance: {Amount(unit=unit, amount=wallet.available_balance)}") + await wallet.redeem(proofs) + print(f"Balance: {Amount(unit=unit, amount=wallet.available_balance)}") + + return GatewayMeltResponse( + paid=True, + payment_preimage=payment.preimage, + ) diff --git a/cashu/gateway/main.py b/cashu/gateway/main.py new file mode 100644 index 00000000..961a7b35 --- /dev/null +++ b/cashu/gateway/main.py @@ -0,0 +1,55 @@ +from typing import Optional + +import click +import uvicorn +from click import Context + +from ..core.settings import settings + + +@click.command( + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + ) +) +@click.option("--port", default=settings.gateway_listen_port, help="Port to listen on") +@click.option( + "--host", default=settings.gateway_listen_host, help="Host to run mint on" +) +@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile") +@click.option("--ssl-certfile", default=None, help="Path to SSL certificate") +@click.pass_context +def main( + ctx: Context, + port: int = settings.gateway_listen_port, + host: str = settings.gateway_listen_host, + ssl_keyfile: Optional[str] = None, + ssl_certfile: Optional[str] = None, +): + """This routine starts the uvicorn server if the Cashu mint is + launched with `poetry run mint` at root level""" + # this beautiful beast parses all command line arguments and passes them to the uvicorn server + d = dict() + for a in ctx.args: + item = a.split("=") + if len(item) > 1: # argument like --key=value + d[item[0].strip("--").replace("-", "_")] = ( + int(item[1]) # need to convert to int if it's a number + if item[1].isdigit() + else item[1] + ) + else: + d[a.strip("--")] = True # argument like --key + + config = uvicorn.Config( + "cashu.gateway.app:app", + port=port, + host=host, + ssl_keyfile=ssl_keyfile, + ssl_certfile=ssl_certfile, + **d, # type: ignore + ) + + server = uvicorn.Server(config) + server.run() diff --git a/cashu/gateway/migrations.py b/cashu/gateway/migrations.py new file mode 100644 index 00000000..917b048f --- /dev/null +++ b/cashu/gateway/migrations.py @@ -0,0 +1,39 @@ +from ..core.db import Connection, Database, table_with_schema + + +async def m000_create_migrations_table(conn: Connection): + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {table_with_schema(conn, 'dbversions')} ( + db TEXT PRIMARY KEY, + version INT NOT NULL + ) + """ + ) + + +async def m001_initial(db: Database): + async with db.connect() as conn: + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'melt_quotes')} ( + mint TEXT NOT NULL, + quote TEXT NOT NULL, + method TEXT NOT NULL, + request TEXT NOT NULL, + checking_id TEXT NOT NULL, + expiry TIMESTAMP NOT NULL, + unit TEXT NOT NULL, + amount {db.big_int} NOT NULL, + fee_reserve {db.big_int}, + paid BOOL NOT NULL, + created_time TIMESTAMP, + paid_time TIMESTAMP, + fee_paid {db.big_int}, + proof TEXT, + + UNIQUE (quote) + + ); + """ + ) diff --git a/cashu/gateway/models.py b/cashu/gateway/models.py new file mode 100644 index 00000000..132b1484 --- /dev/null +++ b/cashu/gateway/models.py @@ -0,0 +1,66 @@ +from typing import List, Optional, Union + +from pydantic import BaseModel, Field + +from ..core.base import ( + Proof, +) +from ..core.settings import settings + + +class GatewayMint(BaseModel): + mint: str + + +class GatewayInfo(BaseModel): + mints: List[GatewayMint] + + +class GatewayMintQuoteRequest(BaseModel): + unit: str = Field(..., max_length=settings.mint_max_request_length) # output unit + amount: int = Field(..., gt=0) # output amount + payment_hash: str = Field( + ..., max_length=settings.mint_max_request_length + ) # payment hash + mint: str = Field(..., max_length=settings.mint_max_request_length) # mint url + + +class GatewayMintQuoteResponse(BaseModel): + quote: str # quote id + request: str # input payment request + paid: bool # whether the request has been paid + expiry: Optional[int] # expiry of the quote + + +class GatewayMintRequest(BaseModel): + quote: str = Field(..., max_length=settings.mint_max_request_length) # quote id + + +class GatewayMintResponse(BaseModel): + inputs: List[Proof] = Field(..., max_items=settings.mint_max_request_length) + + +class GatewayMeltQuoteRequest(BaseModel): + unit: str = Field(..., max_length=settings.mint_max_request_length) # input unit + request: str = Field( + ..., max_length=settings.mint_max_request_length + ) # output payment request + mint: str = Field(..., max_length=settings.mint_max_request_length) # mint url + + +class GatewayMeltQuoteResponse(BaseModel): + quote: str # quote id + pubkey: str # P2PK pubkey of the gateway + amount: int # input amount + expiry: int # expiry of the quote + paid: bool # whether the quote has been paid + + +class GatewayMeltRequest(BaseModel): + quote: str = Field(..., max_length=settings.mint_max_request_length) # quote id + inputs: List[Proof] = Field(..., max_items=settings.mint_max_request_length) + + +class GatewayMeltResponse(BaseModel): + paid: Union[bool, None] + payment_preimage: Union[str, None] diff --git a/cashu/gateway/router.py b/cashu/gateway/router.py new file mode 100644 index 00000000..ac1ab997 --- /dev/null +++ b/cashu/gateway/router.py @@ -0,0 +1,147 @@ +from fastapi import APIRouter, Request +from loguru import logger + +from ..core.models import ( + PostMeltResponse, + PostMintResponse, +) +from .models import ( + GatewayInfo, + GatewayMeltQuoteRequest, + GatewayMeltQuoteResponse, + GatewayMeltRequest, + GatewayMeltResponse, + GatewayMint, + GatewayMintQuoteRequest, + GatewayMintQuoteResponse, + GatewayMintRequest, + GatewayMintResponse, +) +from .startup import gateway + +router: APIRouter = APIRouter() + + +@router.get( + "/v1/info", + summary="Get gateway information", + response_model=GatewayInfo, +) +async def get_info(request: Request) -> GatewayInfo: + """ + Get information about the gateway. + """ + logger.trace("> GET /v1/info") + info = GatewayInfo(mints=[GatewayMint(mint=url) for url in gateway.wallets.keys()]) + logger.trace("< GET /v1/info") + return info + + +@router.post( + "/v1/mint/quote/bolt11", + summary="Request a quote for minting tokens", + response_model=GatewayMintQuoteResponse, + response_description="Mint tokens for a payment on a supported payment method.", +) +async def get_mint_quote( + request: Request, payload: GatewayMintQuoteRequest +) -> GatewayMintQuoteResponse: + """ + Request a quote for minting tokens. + """ + logger.trace(f"> POST /v1/mint/quote/bolt11: {payload}") + quote = await gateway.gateway_mint_quote(payload) + logger.trace(f"< POST /v1/mint/quote/bolt11: {quote}") + return quote + + +@router.post( + "/v1/mint/bolt11", + name="Mint tokens", + summary=( + "Mint tokens for a Bitcoin payment that the mint will make for the user in" + " exchange" + ), + response_model=PostMintResponse, + response_description=( + "The state of the payment, a preimage as proof of payment, and a list of" + " promises for change." + ), +) +async def mint(request: Request, payload: GatewayMintRequest) -> GatewayMintResponse: + """ + Requests tokens to be destroyed and sent out via Lightning. + """ + logger.trace(f"> POST /v1/mint/bolt11: {payload}") + # TODO: CHECK IF IT WORKS (WIP COMMIT) + mint_response = await gateway.gateway_mint(quote=payload.quote) + logger.trace(f"< POST /v1/mint/bolt11: {mint_response}") + return mint_response + + +@router.post( + "/v1/melt/quote/bolt11", + summary="Request a quote for melting tokens", + response_model=GatewayMeltQuoteResponse, + response_description="Melt tokens for a payment on a supported payment method.", +) +async def get_melt_quote( + request: Request, payload: GatewayMeltQuoteRequest +) -> GatewayMeltQuoteResponse: + """ + Request a quote for melting tokens. + """ + logger.trace(f"> POST /v1/melt/quote/bolt11: {payload}") + quote = await gateway.gateway_melt_quote(payload) + logger.trace(f"< POST /v1/melt/quote/bolt11: {quote}") + return quote + + +@router.get( + "/v1/melt/quote/bolt11/{quote}", + summary="Get melt quote", + response_model=GatewayMeltQuoteResponse, + response_description="Get an existing melt quote to check its status.", +) +async def melt_quote(request: Request, quote: str) -> GatewayMeltQuoteResponse: + """ + Get melt quote state. + """ + logger.trace(f"> GET /v1/melt/quote/bolt11/{quote}") + melt_quote = await gateway.gateway_get_melt_quote( + quote, check_quote_with_backend=True + ) + resp = GatewayMeltQuoteResponse( + pubkey=melt_quote.pubkey, + quote=melt_quote.quote, + amount=melt_quote.amount, + paid=melt_quote.paid, + expiry=melt_quote.expiry, + ) + logger.trace(f"< GET /v1/melt/quote/bolt11/{quote}") + return resp + + +@router.post( + "/v1/melt/bolt11", + name="Melt tokens", + summary=( + "Melt tokens for a Bitcoin payment that the mint will make for the user in" + " exchange" + ), + response_model=PostMeltResponse, + response_description=( + "The state of the payment, a preimage as proof of payment, and a list of" + " promises for change." + ), +) +async def melt(request: Request, payload: GatewayMeltRequest) -> GatewayMeltResponse: + """ + Requests tokens to be destroyed and sent out via Lightning. + """ + logger.trace(f"> POST /v1/melt/bolt11: {payload}") + melt_response = await gateway.gateway_melt( + proofs=payload.inputs, quote=payload.quote + ) + logger.trace(f"< POST /v1/melt/bolt11: {melt_response}") + return melt_response diff --git a/cashu/gateway/startup.py b/cashu/gateway/startup.py new file mode 100644 index 00000000..b9fa8bd9 --- /dev/null +++ b/cashu/gateway/startup.py @@ -0,0 +1,66 @@ +# startup routine of the standalone app. These are the steps that need +# to be taken by external apps importing the cashu gateway. + +import importlib +from typing import Dict + +from loguru import logger + +from ..core.base import Method, Unit +from ..core.db import Database +from ..core.migrations import migrate_databases +from ..core.settings import settings +from ..lightning.base import LightningBackend +from . import migrations +from .crud import GatewayCrudSqlite +from .gateway import Gateway + +# kill the program if python runs in non-__debug__ mode +# which could lead to asserts not being executed for optimized code +if not __debug__: + raise Exception("Nutshell cannot run in non-debug mode.") + +# logger.debug("Enviroment Settings:") +# for key, value in settings.dict().items(): +# if key in [ +# "gateway_private_key", +# "gateway_seed_decryption_key", +# "nostr_private_key", +# "gateway_lnbits_key", +# "gateway_blink_key", +# "gateway_strike_key", +# "gateway_lnd_rest_macaroon", +# "gateway_lnd_rest_admin_macaroon", +# "gateway_lnd_rest_invoice_macaroon", +# "gateway_corelightning_rest_macaroon", +# ]: +# value = "********" if value is not None else None +# logger.debug(f"{key}: {value}") + +wallets_module = importlib.import_module("cashu.lightning") + +backends: Dict[Method, Dict[Unit, LightningBackend]] = {} +if settings.gateway_backend_bolt11_sat: + backend_bolt11_sat = getattr(wallets_module, settings.gateway_backend_bolt11_sat)( + unit=Unit.sat + ) + backends.setdefault(Method.bolt11, {})[Unit.sat] = backend_bolt11_sat +if not backends: + raise Exception("No backends are set.") + +if not settings.gateway_private_key: + raise Exception("No gateway private key is set.") + +gateway = Gateway( + db=Database("gateway", settings.gateway_database), + seed=settings.gateway_private_key, + backends=backends, + crud=GatewayCrudSqlite(), +) + + +async def start_gateway_init(): + await migrate_databases(gateway.db, migrations) + await gateway.init_wallets() + await gateway.startup_gateway() + logger.info("Gateway started.") diff --git a/cashu/lightning/base.py b/cashu/lightning/base.py index afde6786..2984f2e6 100644 --- a/cashu/lightning/base.py +++ b/cashu/lightning/base.py @@ -68,6 +68,7 @@ def __str__(self) -> str: class LightningBackend(ABC): supports_mpp: bool = False + supports_hold_invoices: bool = False supported_units: set[Unit] unit: Unit @@ -128,6 +129,19 @@ async def get_payment_quote( # def paid_invoices_stream(self) -> AsyncGenerator[str, None]: # pass + @abstractmethod + async def create_hold_invoice( + self, + payment_hash: str, + amount: Amount, + expiry: int = 3600, + ) -> InvoiceResponse: + pass + + @abstractmethod + async def resolve_hold_invoice(self, preimage: str) -> PaymentResponse: + pass + class Unsupported(Exception): pass diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index 477e84f4..2631322d 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -159,7 +159,7 @@ async def pay_invoice( invoice = bolt11.decode(quote.request) if invoice.amount_msat: amount_msat = int(invoice.amount_msat) - if amount_msat != quote.amount * 1000 and self.supports_mpp: + if amount_msat < quote.amount * 1000 and self.supports_mpp: return await self.pay_partial_invoice( quote, Amount(Unit.sat, quote.amount), fee_limit_msat ) @@ -399,3 +399,80 @@ async def get_payment_quote( fee=fees.to(self.unit, round="up"), amount=amount.to(self.unit, round="up"), ) + + async def create_hold_invoice( + self, + payment_hash: str, + amount: Amount, + expiry: int = 3600, + ) -> InvoiceResponse: + self.assert_unit_supported(amount.unit) + payment_hash_bytes = bytes.fromhex(payment_hash) + data: Dict = { + "hash": base64.b64encode(payment_hash_bytes).decode(), + "value": amount.to(Unit.sat).amount, + "expiry": expiry, + "private": False, + } + + try: + r = await self.client.post(url="/v2/invoices/hodl", json=data) + except Exception as e: + raise Exception(f"Failed to create hold invoice: {e}") + + if r.is_error: + error_message = r.text + try: + error_message = r.json().get("error", error_message) + except Exception: + pass + return InvoiceResponse( + ok=False, + checking_id=None, + payment_request=None, + error_message=error_message, + ) + + data = r.json() + payment_request = data["payment_request"] + checking_id = payment_hash + + return InvoiceResponse( + ok=True, + checking_id=checking_id, + payment_request=payment_request, + error_message=None, + ) + + async def resolve_hold_invoice(self, preimage: str) -> PaymentResponse: + preimage_bytes = bytes.fromhex(preimage) + data: Dict = { + "preimage": base64.b64encode(preimage_bytes).decode(), + } + + try: + r = await self.client.post(url="/v2/invoices/settle", json=data) + except Exception as e: + raise Exception(f"Failed to resolve hold invoice: {e}") + + if r.is_error: + error_message = r.text + try: + error_message = r.json().get("error", error_message) + except Exception: + pass + return PaymentResponse( + ok=False, + checking_id=None, + fee=None, + preimage=None, + error_message=error_message, + ) + + return PaymentResponse( + ok=True, + checking_id=None, + fee=None, + preimage=preimage, + error_message=None, + ) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 01cb936c..1939633b 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -610,7 +610,6 @@ async def melt_quote( ) if mint_quote: payment_quote = self.create_internal_melt_quote(mint_quote, melt_quote) - else: # not internal # verify that the backend supports mpp if the quote request has an amount diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index e66be504..96a11c08 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -50,11 +50,11 @@ unit=Unit.sat ) backends.setdefault(Method.bolt11, {})[Unit.sat] = backend_bolt11_sat -if settings.mint_backend_bolt11_usd: - backend_bolt11_usd = getattr(wallets_module, settings.mint_backend_bolt11_usd)( - unit=Unit.usd - ) - backends.setdefault(Method.bolt11, {})[Unit.usd] = backend_bolt11_usd +# if settings.mint_backend_bolt11_usd: +# backend_bolt11_usd = getattr(wallets_module, settings.mint_backend_bolt11_usd)( +# unit=Unit.usd +# ) +# backends.setdefault(Method.bolt11, {})[Unit.usd] = backend_bolt11_usd if not backends: raise Exception("No backends are set.") diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 60747f9d..088989d0 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import asyncio +import hashlib import os import time from datetime import datetime, timezone @@ -11,6 +12,7 @@ from os.path import isdir, join from typing import Optional +import bolt11 import click from click import Context from loguru import logger @@ -27,9 +29,11 @@ get_reserved_proofs, get_seed_and_mnemonic, ) +from ...wallet.gateway import LOCKTIME_SAFETY, WalletGateway from ...wallet.wallet import Wallet as Wallet from ..api.api_server import start_api_server from ..cli.cli_helpers import ( + get_gateway, get_mint_wallet, get_unit_wallet, print_balance, @@ -188,11 +192,26 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool): @click.option( "--yes", "-y", default=False, is_flag=True, help="Skip confirmation.", type=bool ) +@click.option( + "--gateway", + "-g", + default=False, + is_flag=True, + help="Use Lightning gateway.", +) @click.pass_context @coro async def pay( - ctx: Context, invoice: str, amount: Optional[int] = None, yes: bool = False + ctx: Context, + invoice: str, + amount: Optional[int] = None, + yes: bool = False, + gateway: bool = False, ): + if gateway: + await pay_gateway(ctx, invoice, yes) + return + wallet: Wallet = ctx.obj["WALLET"] await wallet.load_mint() await print_balance(ctx) @@ -235,6 +254,67 @@ async def pay( await print_balance(ctx) +# @cli.command("gateway", help="Pay invoice over Lightning gateway.") +# @click.argument("invoice", type=str) +# @click.option( +# "--yes", "-y", default=False, is_flag=True, help="Skip confirmation.", type=bool +# ) +# @click.pass_context +# @coro +async def pay_gateway(ctx: Context, invoice: str, yes: bool): + _wallet: Wallet = ctx.obj["WALLET"] + await print_balance(ctx) + gateway_url = get_gateway(ctx) + + async def mint_wallet( + mint_url: Optional[str] = None, raise_connection_error: bool = True + ) -> WalletGateway: + lightning_wallet = await WalletGateway.with_db( + mint_url or settings.mint_url, + db=os.path.join(settings.cashu_dir, settings.wallet_name), + name=settings.wallet_name, + ) + lightning_wallet.gateway = gateway_url + await lightning_wallet.async_init(raise_connection_error=raise_connection_error) + return lightning_wallet + + wallet = await mint_wallet() + await wallet.load_mint() + await wallet.load_proofs() + + bolt11_invoice = bolt11.decode(invoice) + assert bolt11_invoice.date + assert bolt11_invoice.expiry + assert bolt11_invoice.amount_msat + + gateway_quote = await wallet.gateway_melt_quote(invoice) + if not yes: + fee = gateway_quote.amount - bolt11_invoice.amount_msat // 1000 + fee_str = "" + if fee: + fee_str = f"(with {wallet.unit.str(fee)} fees)" + message = f"Pay {wallet.unit.str(gateway_quote.amount)} {fee_str} via gateway?" + click.confirm( + message, + abort=True, + default=True, + ) + secret = await wallet.create_htlc_lock( + preimage_hash=bolt11_invoice.payment_hash, + hashlock_pubkey=gateway_quote.pubkey, + locktime_absolute=gateway_quote.expiry + LOCKTIME_SAFETY, + locktime_pubkey=await wallet.create_p2pk_pubkey(), + ) + _, send_proofs = await wallet.split_to_send( + wallet.proofs, gateway_quote.amount, secret_lock=secret, set_reserved=True + ) + + # _, proofs = await wallet.split_to_send(wallet.proofs, gateway_quote.amount) + await wallet.load_proofs() + _ = await wallet.gateway_melt(quote=gateway_quote.quote, proofs=send_proofs) + await print_balance(ctx) + + @cli.command("invoice", help="Create Lighting invoice.") @click.argument("amount", type=float) @click.option("--id", default="", help="Id of the paid invoice.", type=str) @@ -253,9 +333,21 @@ async def pay( help="Do not check if invoice is paid.", type=bool, ) +@click.option( + "--gateway", + "-g", + default=False, + is_flag=True, + help="Use Lightning gateway.", +) @click.pass_context @coro -async def invoice(ctx: Context, amount: float, id: str, split: int, no_check: bool): +async def invoice( + ctx: Context, amount: float, id: str, split: int, no_check: bool, gateway: bool +): + if gateway: + await invoice_gateway(ctx, amount, id, split, no_check) + return wallet: Wallet = ctx.obj["WALLET"] await wallet.load_mint() await print_balance(ctx) @@ -323,6 +415,37 @@ async def invoice(ctx: Context, amount: float, id: str, split: int, no_check: bo return +async def invoice_gateway( + ctx: Context, amount: float, id: str, split: int, no_check: bool +): + _wallet: Wallet = ctx.obj["WALLET"] + await print_balance(ctx) + gateway_url = get_gateway(ctx) + + async def mint_wallet( + mint_url: Optional[str] = None, raise_connection_error: bool = True + ) -> WalletGateway: + lightning_wallet = await WalletGateway.with_db( + mint_url or settings.mint_url, + db=os.path.join(settings.cashu_dir, settings.wallet_name), + name=settings.wallet_name, + ) + lightning_wallet.gateway = gateway_url + await lightning_wallet.async_init(raise_connection_error=raise_connection_error) + return lightning_wallet + + wallet = await mint_wallet() + await wallet.load_mint() + await wallet.load_proofs() + + amount = int(amount * 100) if wallet.unit == Unit.usd else int(amount) + preimage = os.urandom(32) + payment_hash = hashlib.sha256(preimage).hexdigest() + + gateway_quote = await wallet.gateway_mint_quote(amount, payment_hash) + print(f"Quote: {gateway_quote}") + + @cli.command("swap", help="Swap funds between mints.") @click.pass_context @coro diff --git a/cashu/wallet/cli/cli_helpers.py b/cashu/wallet/cli/cli_helpers.py index c0c02df9..9d8ea837 100644 --- a/cashu/wallet/cli/cli_helpers.py +++ b/cashu/wallet/cli/cli_helpers.py @@ -109,6 +109,38 @@ async def get_mint_wallet(ctx: Context, force_select: bool = False): return mint_wallet +def get_gateway(ctx: Context): + """ + Helper function that shows all settings.wallet_gateways and asks the user to select one. + """ + if not settings.wallet_gateways: + raise Exception( + "No gateways are set. Use the environment variable WALLET_GATEWAYS=[] to add a list of Gateway URLs." + ) + gateway = settings.wallet_gateways[0] + if len(settings.wallet_gateways) > 1: + print(f"You have {len(settings.wallet_gateways)} gateways:") + print("") + for i, gw in enumerate(settings.wallet_gateways): + print(f"Gateway {i+1}: {gw}") + print("") + gateway_nr_str = input( + f"Select gateway [1-{len(settings.wallet_gateways)}] or " + f"press enter for default '{settings.wallet_gateways[0]}': " + ) + if not gateway_nr_str: # default gateway + gateway = settings.wallet_gateways[0] + elif gateway_nr_str.isdigit() and int(gateway_nr_str) <= len( + settings.wallet_gateways + ): # specific gateway + gateway = settings.wallet_gateways[int(gateway_nr_str) - 1] + else: + raise Exception("invalid input.") + print(f"Selected gateway: {gateway}") + print("") + return gateway + + async def print_mint_balances(wallet: Wallet, show_mints: bool = False): """ Helper function that prints the balances for each mint URL that we have tokens from. diff --git a/cashu/wallet/htlc.py b/cashu/wallet/htlc.py index 58e32df1..55f0f1ed 100644 --- a/cashu/wallet/htlc.py +++ b/cashu/wallet/htlc.py @@ -20,10 +20,14 @@ async def create_htlc_lock( preimage: Optional[str] = None, preimage_hash: Optional[str] = None, hashlock_pubkey: Optional[str] = None, + locktime_absolute: Optional[int] = None, locktime_seconds: Optional[int] = None, locktime_pubkey: Optional[str] = None, ) -> HTLCSecret: tags = Tags() + if locktime_absolute: + tags["locktime"] = str(locktime_absolute) + if locktime_seconds: tags["locktime"] = str( int((datetime.now() + timedelta(seconds=locktime_seconds)).timestamp()) diff --git a/cashu/wallet/protocols.py b/cashu/wallet/protocols.py index 1f381a19..a3c7c290 100644 --- a/cashu/wallet/protocols.py +++ b/cashu/wallet/protocols.py @@ -21,6 +21,10 @@ class SupportsKeysets(Protocol): unit: Unit +class SupportsUnit(Protocol): + unit: Unit + + class SupportsHttpxClient(Protocol): httpx: httpx.AsyncClient diff --git a/pyproject.toml b/pyproject.toml index d3e7d289..27281c1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] mint = "cashu.mint.main:main" cashu = "cashu.wallet.cli.cli:cli" +gateway = "cashu.gateway.main:main" wallet-test = "tests.test_wallet:test" [tool.ruff]