From 0d239b26f5ccbec029788dbfd14ff9240125a204 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Sat, 3 May 2025 17:11:42 +0200 Subject: [PATCH 1/5] creating nodeless lib --- fly/Dockerfile | 30 ++-- fly/example_client.py | 170 ++++++++++++++------ fly/main.py | 366 +++++++++++++++++++++--------------------- fly/pyproject.toml | 2 +- 4 files changed, 326 insertions(+), 242 deletions(-) diff --git a/fly/Dockerfile b/fly/Dockerfile index be29618..58c49c3 100644 --- a/fly/Dockerfile +++ b/fly/Dockerfile @@ -10,6 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ python3-venv \ python3-pip \ + python3-full \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -17,24 +18,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1 && \ update-alternatives --install /usr/bin/pip pip /usr/bin/pip3 1 -# Install Poetry -RUN pip install poetry --break-system-packages +# Create a virtual environment +RUN python3 -m venv /app/venv + +# Install Poetry in the virtual environment +RUN /app/venv/bin/pip install poetry # Copy project files -COPY pyproject.toml . -COPY main.py . +COPY fly/pyproject.toml . +COPY fly/main.py . +COPY nodeless.py . +# Copy environment file template +COPY fly/.env.example .env # Create a README.md file if it doesn't exist to satisfy Poetry RUN touch README.md -# Copy environment file template -COPY .env.example .env - -# Configure Poetry to not create a virtual environment -RUN poetry config virtualenvs.create false - -# Install dependencies without installing the project itself -RUN poetry install --no-interaction --no-ansi --no-root +# Install dependencies using the virtual environment's pip +RUN /app/venv/bin/poetry install --no-interaction --no-ansi --no-root # Create tmp directory for Breez SDK RUN mkdir -p ./tmp @@ -44,6 +45,7 @@ EXPOSE 8000 # Set environment variables ENV PYTHONUNBUFFERED=1 +ENV PATH="/app/venv/bin:$PATH" -# Run the application -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +# Run the application (now using the venv's Python) +CMD ["/app/venv/bin/uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/fly/example_client.py b/fly/example_client.py index a097dec..bafdbaf 100644 --- a/fly/example_client.py +++ b/fly/example_client.py @@ -46,51 +46,36 @@ class BreezClient: ) return self._handle_response(response) - def receive_payment(self, amount, method="LIGHTNING"): - """ - Generate a Lightning/Bitcoin/Liquid invoice to receive payment. - - Args: - amount (int): Amount in satoshis to receive - method (str, optional): Payment method (LIGHTNING or LIQUID) - - Returns: - dict: JSON response with invoice details - """ + def receive_payment(self, amount, method="LIGHTNING", description=None, asset_id=None): payload = { "amount": amount, "method": method } + if description is not None: + payload["description"] = description + if asset_id is not None: + payload["asset_id"] = asset_id response = requests.post( - f"{self.api_url}/receive_payment", - json=payload, + f"{self.api_url}/receive_payment", + json=payload, headers=self.headers ) return self._handle_response(response) - def send_payment(self, destination, amount=None, drain=False): - """ - Send a payment via Lightning or Liquid. - - Args: - destination (str): Payment destination (invoice or address) - amount (int, optional): Amount in satoshis to send - drain (bool, optional): Whether to drain the wallet - - Returns: - dict: JSON response with payment details - """ + def send_payment(self, destination, amount_sat=None, amount_asset=None, asset_id=None, drain=False): payload = { - "destination": destination + "destination": destination, + "drain": drain } - if amount is not None: - payload["amount"] = amount - if drain: - payload["drain"] = True - + if amount_sat is not None: + payload["amount_sat"] = amount_sat + if amount_asset is not None: + payload["amount_asset"] = amount_asset + if asset_id is not None: + payload["asset_id"] = asset_id response = requests.post( - f"{self.api_url}/send_payment", - json=payload, + f"{self.api_url}/send_payment", + json=payload, headers=self.headers ) return self._handle_response(response) @@ -105,6 +90,86 @@ class BreezClient: response = requests.get(f"{self.api_url}/health") return self._handle_response(response) + def send_onchain(self, address, amount_sat=None, drain=False, fee_rate_sat_per_vbyte=None): + """ + Send an onchain (Bitcoin or Liquid) payment. + Args: + address (str): Destination address + amount_sat (int, optional): Amount in satoshis + drain (bool, optional): Drain all funds + fee_rate_sat_per_vbyte (int, optional): Custom fee rate + Returns: + dict: JSON response + """ + payload = { + "address": address, + "drain": drain + } + if amount_sat is not None: + payload["amount_sat"] = amount_sat + if fee_rate_sat_per_vbyte is not None: + payload["fee_rate_sat_per_vbyte"] = fee_rate_sat_per_vbyte + response = requests.post( + f"{self.api_url}/send_onchain", + json=payload, + headers=self.headers + ) + return self._handle_response(response) + + # LNURL-related endpoints (all under /v1/ln/) + def parse_input(self, input_str): + response = requests.post( + f"{self.api_url}/v1/ln/parse_input", + json={"input": input_str}, + headers=self.headers + ) + return self._handle_response(response) + + def prepare_lnurl_pay(self, data, amount_sat, comment=None, validate_success_action_url=True): + payload = { + "data": data, + "amount_sat": amount_sat, + "comment": comment, + "validate_success_action_url": validate_success_action_url + } + response = requests.post( + f"{self.api_url}/v1/ln/prepare_lnurl_pay", + json=payload, + headers=self.headers + ) + return self._handle_response(response) + + def lnurl_pay(self, prepare_response): + payload = {"prepare_response": prepare_response} + response = requests.post( + f"{self.api_url}/v1/ln/lnurl_pay", + json=payload, + headers=self.headers + ) + return self._handle_response(response) + + def lnurl_auth(self, data): + payload = {"data": data} + response = requests.post( + f"{self.api_url}/v1/ln/lnurl_auth", + json=payload, + headers=self.headers + ) + return self._handle_response(response) + + def lnurl_withdraw(self, data, amount_msat, comment=None): + payload = { + "data": data, + "amount_msat": amount_msat, + "comment": comment + } + response = requests.post( + f"{self.api_url}/v1/ln/lnurl_withdraw", + json=payload, + headers=self.headers + ) + return self._handle_response(response) + def _handle_response(self, response): """Helper method to handle API responses.""" try: @@ -123,7 +188,7 @@ class BreezClient: if __name__ == "__main__": # Configuration API_URL = "http://localhost:8000" # Change to your deployed API URL - API_KEY = "" # Set your API key here + API_KEY = "kurac" # Set your API key here # Initialize client breez = BreezClient(api_url=API_URL, api_key=API_KEY) @@ -135,14 +200,29 @@ if __name__ == "__main__": # List payments print("\nšŸ”„ Listing Payments...") print(json.dumps(breez.list_payments(), indent=2)) - - # Generate an invoice to receive payment - #print("\nšŸ’° Generating invoice to receive payment...") - #invoice = breez.receive_payment(amount=1000, method="LIGHTNING") - #print(json.dumps(invoice, indent=2)) - #print(f"Invoice: {invoice.get('destination', 'Error generating invoice')}") - - # Send payment example (commented out for safety) - #print("\nšŸš€ Sending Payment...") - #result = breez.send_payment(destination="", amount=1111) - #print(json.dumps(result, indent=2)) \ No newline at end of file + + # LNURL Example Usage + # lnurl = "lnurl1dp68gurn8ghj7mrww4exctnrdakj7mrww4exctnrdakj7mrww4exctnrdakj7" # Replace with a real LNURL + # print("\nšŸ”Ž Parsing LNURL...") + # parsed = breez.parse_input(lnurl) + # print(json.dumps(parsed, indent=2)) + # if parsed.get("type") == "LN_URL_PAY": + # print("\nšŸ“ Preparing LNURL-Pay...") + # prepare = breez.prepare_lnurl_pay(parsed["data"], amount_sat=1000) + # print(json.dumps(prepare, indent=2)) + # print("\nšŸš€ Executing LNURL-Pay...") + # result = breez.lnurl_pay(prepare) + # print(json.dumps(result, indent=2)) + # elif parsed.get("type") == "LN_URL_AUTH": + # print("\nšŸ” Executing LNURL-Auth...") + # result = breez.lnurl_auth(parsed["data"]) + # print(json.dumps(result, indent=2)) + # elif parsed.get("type") == "LN_URL_WITHDRAW": + # print("\nšŸ’ø Executing LNURL-Withdraw...") + # result = breez.lnurl_withdraw(parsed["data"], amount_msat=1000_000) + # print(json.dumps(result, indent=2)) + + # Onchain payment example (commented out for safety) + # print("\nā›“ļø Sending Onchain Payment...") + # result = breez.send_onchain(address="bitcoin_address_here", amount_sat=10000) + # print(json.dumps(result, indent=2)) \ No newline at end of file diff --git a/fly/main.py b/fly/main.py index a200ec4..13525fa 100644 --- a/fly/main.py +++ b/fly/main.py @@ -1,73 +1,53 @@ -from fastapi import FastAPI, Depends, HTTPException, Header, Query +from fastapi import FastAPI, Depends, HTTPException, Header, Query, APIRouter from fastapi.security.api_key import APIKeyHeader from pydantic import BaseModel, Field from typing import Optional, Dict, List, Any, Union import os -import json from dotenv import load_dotenv from enum import Enum -from breez_sdk_liquid import ( - LiquidNetwork, - PayAmount, - ConnectRequest, - PrepareSendRequest, - SendPaymentRequest, - PrepareReceiveRequest, - ReceivePaymentRequest, - EventListener, - SdkEvent, - connect, - default_config, - PaymentMethod, - ListPaymentsRequest, - ReceiveAmount -) +from nodeless import PaymentHandler # Load environment variables load_dotenv() -# Create FastAPI app app = FastAPI( title="Breez Nodeless Payments API", description="A FastAPI implementation of Breez SDK for Lightning/Liquid payments", version="1.0.0" ) -# API Key authentication API_KEY_NAME = "x-api-key" api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) - -# Configure API key from environment variable API_KEY = os.getenv("API_SECRET") -BREEZ_API_KEY = os.getenv("BREEZ_API_KEY") -SEED_PHRASE = os.getenv("SEED_PHRASE") -# Models for request/response +from fastapi import APIRouter + +ln_router = APIRouter(prefix="/v1/lnurl", tags=["lnurl"]) + +# --- Models --- class PaymentMethodEnum(str, Enum): LIGHTNING = "LIGHTNING" - LIQUID = "LIQUID" + BITCOIN_ADDRESS = "BITCOIN_ADDRESS" + LIQUID_ADDRESS = "LIQUID_ADDRESS" class ReceivePaymentBody(BaseModel): amount: int = Field(..., description="Amount in satoshis to receive") method: PaymentMethodEnum = Field(PaymentMethodEnum.LIGHTNING, description="Payment method") + description: Optional[str] = Field(None, description="Optional description for invoice") + asset_id: Optional[str] = Field(None, description="Asset ID for Liquid asset (optional)") class SendPaymentBody(BaseModel): destination: str = Field(..., description="Payment destination (invoice or address)") - amount: Optional[int] = Field(None, description="Amount in satoshis to send") + amount_sat: Optional[int] = Field(None, description="Amount in satoshis to send (for Bitcoin)") + amount_asset: Optional[float] = Field(None, description="Amount to send (for asset payments)") + asset_id: Optional[str] = Field(None, description="Asset ID for Liquid asset (optional)") drain: bool = Field(False, description="Whether to drain the wallet") -class ListPaymentsParams: - def __init__( - self, - from_timestamp: Optional[int] = Query(None, description="Filter payments from this timestamp"), - to_timestamp: Optional[int] = Query(None, description="Filter payments to this timestamp"), - offset: Optional[int] = Query(None, description="Pagination offset"), - limit: Optional[int] = Query(None, description="Pagination limit") - ): - self.from_timestamp = from_timestamp - self.to_timestamp = to_timestamp - self.offset = offset - self.limit = limit +class SendOnchainBody(BaseModel): + address: str = Field(..., description="Destination Bitcoin or Liquid address") + amount_sat: Optional[int] = Field(None, description="Amount in satoshis to send (ignored if drain)") + drain: bool = Field(False, description="Send all funds") + fee_rate_sat_per_vbyte: Optional[int] = Field(None, description="Custom fee rate (optional)") class PaymentResponse(BaseModel): timestamp: int @@ -75,9 +55,11 @@ class PaymentResponse(BaseModel): fees_sat: int payment_type: str status: str - details: str - destination: str + details: Any + destination: Optional[str] = None tx_id: Optional[str] = None + payment_hash: Optional[str] = None + swap_id: Optional[str] = None class PaymentListResponse(BaseModel): payments: List[PaymentResponse] @@ -87,139 +69,42 @@ class ReceiveResponse(BaseModel): fees_sat: int class SendResponse(BaseModel): - payment_status: str - destination: str - fees_sat: int + status: str + destination: Optional[str] = None + fees_sat: Optional[int] = None + payment_hash: Optional[str] = None + swap_id: Optional[str] = None -# Breez SDK Event Listener -class SdkListener(EventListener): - def __init__(self): - self.synced = False - self.paid = [] +class SendOnchainResponse(BaseModel): + status: str + address: str + fees_sat: Optional[int] = None - def on_event(self, event): - if isinstance(event, SdkEvent.SYNCED): - self.synced = True - if isinstance(event, SdkEvent.PAYMENT_SUCCEEDED): - if event.details.destination: - self.paid.append(event.details.destination) - - def is_paid(self, destination: str): - return destination in self.paid +# LNURL Models +class ParseInputBody(BaseModel): + input: str -# Initialize Breez SDK client -class BreezClient: - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super(BreezClient, cls).__new__(cls) - cls._instance.initialize() - return cls._instance - - def initialize(self): - if not BREEZ_API_KEY: - raise Exception("Missing Breez API key in environment variables") - if not SEED_PHRASE: - raise Exception("Missing seed phrase in environment variables") - - config = default_config(LiquidNetwork.MAINNET, BREEZ_API_KEY) - config.working_dir = './tmp' - connect_request = ConnectRequest(config=config, mnemonic=SEED_PHRASE) - self.instance = connect(connect_request) - self.listener = SdkListener() - self.instance.add_event_listener(self.listener) - self.is_initialized = True - - def wait_for_sync(self, timeout_seconds: int = 30): - """Wait for the SDK to sync before proceeding.""" - import time - start_time = time.time() - while time.time() - start_time < timeout_seconds: - if self.listener.synced: - return True - time.sleep(1) - raise Exception("Sync timeout: SDK did not sync within the allocated time.") - - def list_payments(self, params: ListPaymentsParams) -> List[Dict[str, Any]]: - self.wait_for_sync() - - req = ListPaymentsRequest( - from_timestamp=params.from_timestamp, - to_timestamp=params.to_timestamp, - offset=params.offset, - limit=params.limit - ) - - payments = self.instance.list_payments(req) - payment_list = [] - - for payment in payments: - payment_dict = { - 'timestamp': payment.timestamp, - 'amount_sat': payment.amount_sat, - 'fees_sat': payment.fees_sat, - 'payment_type': str(payment.payment_type), - 'status': str(payment.status), - 'details': str(payment.details), - 'destination': payment.destination, - 'tx_id': payment.tx_id - } - payment_list.append(payment_dict) - - return payment_list - - def receive_payment(self, amount: int, payment_method: str = 'LIGHTNING') -> Dict[str, Any]: - try: - self.wait_for_sync() - except Exception as e: - raise Exception(f"Error during SDK sync: {e}") - - try: - if isinstance(amount, int): - receive_amount = ReceiveAmount.BITCOIN(amount) - else: - receive_amount = amount - prepare_req = PrepareReceiveRequest(payment_method=getattr(PaymentMethod, payment_method), amount=receive_amount) - except Exception as e: - raise Exception(f"Error preparing receive request: {e}") - - try: - prepare_res = self.instance.prepare_receive_payment(prepare_req) - except Exception as e: - raise Exception(f"Error preparing receive payment: {e}") - - try: - req = ReceivePaymentRequest(prepare_response=prepare_res) - res = self.instance.receive_payment(req) - except Exception as e: - raise Exception(f"Error receiving payment: {e}") - - return { - 'destination': res.destination, - 'fees_sat': prepare_res.fees_sat - } - - def send_payment(self, destination: str, amount: Optional[int] = None, drain: bool = False) -> Dict[str, Any]: - self.wait_for_sync() - - pay_amount = PayAmount.DRAIN if drain else PayAmount.BITCOIN(amount) if amount else None - prepare_req = PrepareSendRequest(destination=destination, amount=pay_amount) - prepare_res = self.instance.prepare_send_payment(prepare_req) - req = SendPaymentRequest(prepare_response=prepare_res) - res = self.instance.send_payment(req) - - return { - 'payment_status': 'success', - 'destination': res.payment.destination, - 'fees_sat': prepare_res.fees_sat - } +class PrepareLnurlPayBody(BaseModel): + data: Dict[str, Any] # The .data dict from parse_input + amount_sat: int + comment: Optional[str] = None + validate_success_action_url: Optional[bool] = True -# Dependency for API key validation +class LnurlPayBody(BaseModel): + prepare_response: Dict[str, Any] # The dict from prepare_lnurl_pay + +class LnurlAuthBody(BaseModel): + data: Dict[str, Any] # The .data dict from parse_input + +class LnurlWithdrawBody(BaseModel): + data: Dict[str, Any] # The .data dict from parse_input + amount_msat: int + comment: Optional[str] = None + +# --- Dependencies --- async def get_api_key(api_key: str = Header(None, alias=API_KEY_NAME)): if not API_KEY: raise HTTPException(status_code=500, detail="API key not configured on server") - if api_key != API_KEY: raise HTTPException( status_code=401, @@ -228,22 +113,30 @@ async def get_api_key(api_key: str = Header(None, alias=API_KEY_NAME)): ) return api_key -# Dependency for Breez client -def get_breez_client(): +def get_payment_handler(): try: - return BreezClient() + return PaymentHandler() except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to initialize Breez client: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to initialize PaymentHandler: {str(e)}") -# API Routes +# --- API Endpoints --- @app.get("/list_payments", response_model=PaymentListResponse) async def list_payments( - params: ListPaymentsParams = Depends(), + from_timestamp: Optional[int] = Query(None), + to_timestamp: Optional[int] = Query(None), + offset: Optional[int] = Query(None), + limit: Optional[int] = Query(None), api_key: str = Depends(get_api_key), - client: BreezClient = Depends(get_breez_client) + handler: PaymentHandler = Depends(get_payment_handler) ): try: - payments = client.list_payments(params) + params = { + "from_timestamp": from_timestamp, + "to_timestamp": to_timestamp, + "offset": offset, + "limit": limit + } + payments = handler.list_payments(params) return {"payments": payments} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -252,12 +145,14 @@ async def list_payments( async def receive_payment( request: ReceivePaymentBody, api_key: str = Depends(get_api_key), - client: BreezClient = Depends(get_breez_client) + handler: PaymentHandler = Depends(get_payment_handler) ): try: - result = client.receive_payment( + result = handler.receive_payment( amount=request.amount, - payment_method=request.method + payment_method=request.method.value, + description=request.description, + asset_id=request.asset_id ) return result except Exception as e: @@ -267,23 +162,130 @@ async def receive_payment( async def send_payment( request: SendPaymentBody, api_key: str = Depends(get_api_key), - client: BreezClient = Depends(get_breez_client) + handler: PaymentHandler = Depends(get_payment_handler) ): try: - result = client.send_payment( + result = handler.send_payment( destination=request.destination, - amount=request.amount, + amount_sat=request.amount_sat, + amount_asset=request.amount_asset, + asset_id=request.asset_id, drain=request.drain ) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -# Health check endpoint +@app.post("/send_onchain", response_model=SendOnchainResponse) +async def send_onchain( + request: SendOnchainBody, + api_key: str = Depends(get_api_key), + handler: PaymentHandler = Depends(get_payment_handler) +): + try: + # Prepare onchain payment + prepare = handler.prepare_pay_onchain( + amount_sat=request.amount_sat, + drain=request.drain, + fee_rate_sat_per_vbyte=request.fee_rate_sat_per_vbyte + ) + # Execute onchain payment + handler.pay_onchain( + address=request.address, + prepare_response=prepare + ) + return {"status": "initiated", "address": request.address, "fees_sat": prepare.get("total_fees_sat")} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/onchain_limits") +async def onchain_limits( + api_key: str = Depends(get_api_key), + handler: PaymentHandler = Depends(get_payment_handler) +): + try: + return handler.fetch_onchain_limits() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + @app.get("/health") async def health(): return {"status": "ok"} +@ln_router.post("/parse_input") +async def parse_input( + request: ParseInputBody, + api_key: str = Depends(get_api_key), + handler: PaymentHandler = Depends(get_payment_handler) +): + try: + return handler.parse_input(request.input) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@ln_router.post("/prepare") +async def prepare( + request: PrepareLnurlPayBody, + api_key: str = Depends(get_api_key), + handler: PaymentHandler = Depends(get_payment_handler) +): + try: + from breez_sdk_liquid import LnUrlPayRequestData + data_obj = LnUrlPayRequestData(**request.data) + return handler.prepare_lnurl_pay( + data=data_obj, + amount_sat=request.amount_sat, + comment=request.comment, + validate_success_action_url=request.validate_success_action_url + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@ln_router.post("/pay") +async def pay( + request: LnurlPayBody, + api_key: str = Depends(get_api_key), + handler: PaymentHandler = Depends(get_payment_handler) +): + try: + from breez_sdk_liquid import PrepareLnUrlPayResponse + prepare_obj = PrepareLnUrlPayResponse(**request.prepare_response) + return handler.lnurl_pay(prepare_obj) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@ln_router.post("/auth") +async def auth( + request: LnurlAuthBody, + api_key: str = Depends(get_api_key), + handler: PaymentHandler = Depends(get_payment_handler) +): + try: + from breez_sdk_liquid import LnUrlAuthRequestData + data_obj = LnUrlAuthRequestData(**request.data) + return {"success": handler.lnurl_auth(data_obj)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@ln_router.post("/withdraw") +async def withdraw( + request: LnurlWithdrawBody, + api_key: str = Depends(get_api_key), + handler: PaymentHandler = Depends(get_payment_handler) +): + try: + from breez_sdk_liquid import LnUrlWithdrawRequestData + data_obj = LnUrlWithdrawRequestData(**request.data) + return handler.lnurl_withdraw( + data=data_obj, + amount_msat=request.amount_msat, + comment=request.comment + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +app.include_router(ln_router) + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/fly/pyproject.toml b/fly/pyproject.toml index 48af435..0b10e0d 100644 --- a/fly/pyproject.toml +++ b/fly/pyproject.toml @@ -10,7 +10,7 @@ package-mode = false python = "^3.10" fastapi = "^0.111.0" uvicorn = {extras = ["standard"], version = "^0.30.1"} -breez-sdk-liquid = "*" +breez-sdk-liquid = "0.8.2" python-dotenv = "^1.0.1" requests = "^2.31.0" From cc7246f6b633813d84a9ffe6315c0c3153bcdef4 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Sat, 3 May 2025 17:14:03 +0200 Subject: [PATCH 2/5] nodeless.py --- nodeless.py | 1392 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1392 insertions(+) create mode 100644 nodeless.py diff --git a/nodeless.py b/nodeless.py new file mode 100644 index 0000000..4200674 --- /dev/null +++ b/nodeless.py @@ -0,0 +1,1392 @@ +import json +import os +import argparse +from typing import Optional, List, Dict, Any +from dotenv import load_dotenv +from breez_sdk_liquid import ( + LiquidNetwork, + PayAmount, + ConnectRequest, + PrepareSendRequest, + SendPaymentRequest, + PrepareReceiveRequest, + ReceivePaymentRequest, + EventListener, + SdkEvent, + connect, + default_config, + PaymentMethod, + ListPaymentsRequest, + InputType, # Added for parse functionality + SignMessageRequest, # Added for message signing + CheckMessageRequest, # Added for message checking + BuyBitcoinProvider, # Added for buy bitcoin + PrepareBuyBitcoinRequest, # Added for buy bitcoin + BuyBitcoinRequest, # Added for buy bitcoin + PreparePayOnchainRequest, # Added for pay onchain + PayOnchainRequest, # Added for pay onchain + RefundRequest, # Added for refunds + RefundableSwap, # Added for refunds + FetchPaymentProposedFeesRequest, # Added for fee acceptance + AcceptPaymentProposedFeesRequest, # Added for fee acceptance + PaymentState, # Added for fee acceptance + PaymentDetails, # Added for fee acceptance + AssetMetadata, # Added for assets + ExternalInputParser, # Added for parsers + GetPaymentRequest, # Added for get payment + ListPaymentDetails, # Added for list payments by details + ReceiveAmount, # Ensure ReceiveAmount is in this list! + + # --- Imports for refined method signatures --- + PrepareBuyBitcoinResponse, + PrepareLnUrlPayResponse, + PreparePayOnchainResponse, + # Correct Imports for LNURL Data Objects + LnUrlPayRequestData, # Corrected import for prepare_lnurl_pay + LnUrlAuthRequestData, # Corrected import for lnurl_auth + LnUrlWithdrawRequestData, # Corrected import for lnurl_withdraw + # RefundableSwap already imported + # --- End imports --- +) +import time +import logging +from pprint import pprint + +# Set up logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +class SdkListener(EventListener): + """ + A listener class for handling Breez SDK events. + + This class extends the EventListener from breez_sdk_liquid and implements + custom event handling logic, particularly for tracking successful payments + and other key SDK events. + """ + def __init__(self): + self.synced = False + self.paid = [] + self.refunded = [] # Added for tracking refunds + self.payment_statuses = {} # Track statuses for better payment checking + + def on_event(self, event): + """Handles incoming SDK events.""" + # Log all events at debug level + logger.debug(f"Received SDK event: {event}") + + if isinstance(event, SdkEvent.SYNCED): + self.synced = True + logger.info("SDK SYNCED") + elif isinstance(event, SdkEvent.PAYMENT_SUCCEEDED): + details = event.details + # Determine identifier based on payment type + identifier = None + if hasattr(details, 'destination') and details.destination: + identifier = details.destination + elif hasattr(details, 'payment_hash') and details.payment_hash: + identifier = details.payment_hash + + if identifier: + # Avoid duplicates if the same identifier can be seen multiple times + if identifier not in self.paid: + self.paid.append(identifier) + self.payment_statuses[identifier] = 'SUCCEEDED' + logger.info(f"PAYMENT SUCCEEDED for identifier: {identifier}") + else: + logger.info("PAYMENT SUCCEEDED with no clear identifier.") + + logger.debug(f"Payment Succeeded Details: {details}") # Log full details at debug + + elif isinstance(event, SdkEvent.PAYMENT_FAILED): + details = event.details + # Determine identifier based on payment type + identifier = None + if hasattr(details, 'destination') and details.destination: + identifier = details.destination + elif hasattr(details, 'payment_hash') and details.payment_hash: + identifier = details.payment_hash + elif hasattr(details, 'swap_id') and details.swap_id: + identifier = details.swap_id # Add swap_id as potential identifier + + error = getattr(details, 'error', 'Unknown error') + + if identifier: + self.payment_statuses[identifier] = 'FAILED' + logger.error(f"PAYMENT FAILED for identifier: {identifier}, Error: {error}") + else: + logger.error(f"PAYMENT FAILED with no clear identifier. Error: {error}") + + logger.debug(f"Payment Failed Details: {details}") # Log full details at debug + + def is_paid(self, destination: str) -> bool: + """Checks if a payment to a specific destination has succeeded.""" + # Check both the old list and the status dictionary + return destination in self.paid or self.payment_statuses.get(destination) == 'SUCCEEDED' + + def is_synced(self) -> bool: + """Checks if the SDK is synced.""" + return self.synced + + def get_payment_status(self, identifier: str) -> Optional[str]: + """ + Get the known status for a payment identified by destination, hash, or swap ID. + Returns status string ('SUCCEEDED', 'FAILED', 'REFUNDED', 'PENDING', etc.) or None. + """ + return self.payment_statuses.get(identifier) + + +class PaymentHandler: + """ + A wrapper class for the Breez SDK Nodeless (Liquid implementation). + + This class handles SDK initialization, connection, and provides simplified + methods for common payment and wallet operations. + """ + def __init__(self, network: LiquidNetwork = LiquidNetwork.MAINNET, working_dir: str = '~/.breez-cli', asset_metadata: Optional[List[AssetMetadata]] = None, external_input_parsers: Optional[List[ExternalInputParser]] = None): + """ + Initializes the PaymentHandler and connects to the Breez SDK. + + Args: + network: The Liquid network to use (MAINNET or TESTNET). + working_dir: The directory for SDK files. + asset_metadata: Optional list of AssetMetadata for non-Bitcoin assets. + external_input_parsers: Optional list of ExternalInputParser for custom input parsing. + """ + logger.debug("Entering PaymentHandler.__init__") + load_dotenv() # Load environment variables from .env + + self.breez_api_key = os.getenv('BREEZ_API_KEY') + self.seed_phrase = os.getenv('BREEZ_SEED_PHRASE') + + if not self.breez_api_key: + logger.error("BREEZ_API_KEY not found in environment variables.") + raise Exception("Missing Breez API key in .env file or environment") + if not self.seed_phrase: + logger.error("BREEZ_SEED_PHRASE not found in environment variables.") + raise Exception("Missing seed phrase in .env file or environment") + + logger.info("Retrieved credentials from environment successfully") + + config = default_config(network, self.breez_api_key) + # Expand user path for working_dir + config.working_dir = os.path.expanduser(working_dir) + # Ensure working directory exists + try: + os.makedirs(config.working_dir, exist_ok=True) + logger.debug(f"Ensured working directory exists: {config.working_dir}") + except OSError as e: + logger.error(f"Failed to create working directory {config.working_dir}: {e}") + raise # Re-raise if directory creation fails + + if asset_metadata: + config.asset_metadata = asset_metadata + logger.info(f"Configured asset metadata: {asset_metadata}") + + if external_input_parsers: + config.external_input_parsers = external_input_parsers + logger.info(f"Configured external input parsers: {external_input_parsers}") + + connect_request = ConnectRequest(config=config, mnemonic=self.seed_phrase) + + try: + self.instance = connect(connect_request) + self.listener = SdkListener() + # Add listener immediately after connecting + self.instance.add_event_listener(self.listener) + logger.info("Breez SDK connected successfully.") + except Exception as e: + logger.error(f"Failed to connect to Breez SDK: {e}") + # Re-raise the exception after logging + raise + + logger.debug("Exiting PaymentHandler.__init__") + + + def wait_for_sync(self, timeout_seconds: int = 30): + """Wait for the SDK to sync before proceeding.""" + logger.debug(f"Entering wait_for_sync (timeout={timeout_seconds}s)") + start_time = time.time() + while time.time() - start_time < timeout_seconds: + if self.listener.is_synced(): + logger.debug("SDK synced.") + logger.debug("Exiting wait_for_sync (synced)") + return True + time.sleep(0.5) # Shorter sleep for faster sync detection + logger.error("Sync timeout: SDK did not sync within the allocated time.") + logger.debug("Exiting wait_for_sync (timeout)") + raise Exception(f"Sync timeout: SDK did not sync within {timeout_seconds} seconds.") + + def wait_for_payment(self, identifier: str, timeout_seconds: int = 60) -> bool: + """ + Wait for payment to complete or timeout for a specific identifier + (destination, hash, or swap ID). + """ + logger.debug(f"Entering wait_for_payment (identifier={identifier}, timeout={timeout_seconds}s)") + start_time = time.time() + while time.time() - start_time < timeout_seconds: + status = self.listener.get_payment_status(identifier) + if status == 'SUCCEEDED': + logger.debug(f"Payment for {identifier} succeeded.") + logger.debug("Exiting wait_for_payment (succeeded)") + return True + if status == 'FAILED': + logger.error(f"Payment for {identifier} failed during wait.") + logger.debug("Exiting wait_for_payment (failed)") + return False + # Consider other final states like 'REFUNDED' if applicable + if status == 'REFUNDED': + logger.info(f"Swap for {identifier} was refunded during wait.") + logger.debug("Exiting wait_for_payment (refunded)") + return False + + time.sleep(1) + logger.warning(f"Wait for payment for {identifier} timed out.") + logger.debug("Exiting wait_for_payment (timeout)") + return False + + def disconnect(self): + """Disconnects from the Breez SDK.""" + logger.debug("Entering disconnect") + try: + # Check if the instance attribute exists and is not None + if hasattr(self, 'instance') and self.instance: + self.instance.disconnect() + logger.info("Breez SDK disconnected.") + else: + logger.warning("Disconnect called but SDK instance was not initialized or already disconnected.") + except Exception as e: + logger.error(f"Error disconnecting from Breez SDK: {e}") + # Decide if you want to re-raise or just log depending on context + # raise # Re-raising might prevent clean shutdown + + logger.debug("Exiting disconnect") + + + # --- Wallet Operations --- + def get_info(self) -> Dict[str, Any]: + """ + Fetches general wallet and blockchain information. + + Returns: + Dictionary containing wallet_info and blockchain_info. + """ + logger.debug("Entering get_info") + try: + self.wait_for_sync() + info = self.instance.get_info() + # Convert info object to dictionary for easier handling + info_dict = { + 'wallet_info': info.wallet_info.__dict__ if info.wallet_info else None, + 'blockchain_info': info.blockchain_info.__dict__ if info.blockchain_info else None, + } + logger.debug(f"Fetched wallet info successfully.") + logger.debug("Exiting get_info") + return info_dict + except Exception as e: + logger.error(f"Error getting info: {e}") + logger.debug("Exiting get_info (error)") + raise + + def list_payments(self, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + """ + Lists payment history with optional filters. + + Args: + params: Dictionary with optional filters (from_timestamp, to_timestamp, + offset, limit, filters, details). 'filters' should be a list + of breez_sdk_liquid.PaymentType members. 'details' should be + a breez_sdk_liquid.ListPaymentDetails object. + Returns: + List of payment dictionaries. + Raises: + Exception: For any SDK errors. + """ + logger.debug(f"Entering list_payments with params: {params}") + try: + self.wait_for_sync() + from_ts = int(params.get('from_timestamp')) if params and params.get('from_timestamp') is not None else None + to_ts = int(params.get('to_timestamp')) if params and params.get('to_timestamp') is not None else None + offset = int(params.get('offset')) if params and params.get('offset') is not None else None + limit = int(params.get('limit')) if params and params.get('limit') is not None else None + + # --- Handle optional filters and details --- + filters = params.get('filters') if params else None # Expects List[PaymentType] + details_param = params.get('details') if params else None # Expects ListPaymentDetails + + # Add validation for filters/details types if needed + if filters is not None and not isinstance(filters, list): + logger.warning(f"Invalid type for 'filters' parameter: {type(filters)}") + # Decide whether to raise error or proceed without filter + # raise ValueError("'filters' parameter must be a list of PaymentType") + filters = None # Ignore invalid input + + # Validation for details_param is trickier as it's a union type + # We'll trust the caller passes the correct SDK object or None + + req = ListPaymentsRequest( + from_timestamp=from_ts, + to_timestamp=to_ts, + offset=offset, + limit=limit, + filters=filters, + details=details_param, + ) + # --- End handle optional filters and details --- + + payments = self.instance.list_payments(req) + + # Convert payment objects to dictionaries for easier handling + payment_list = [] + for payment in payments: + # Use a helper function if this conversion becomes complex/repeated + payment_dict = { + 'id': getattr(payment, 'id', None), # Payments might have an ID? Check SDK docs + 'timestamp': payment.timestamp, + 'amount_sat': payment.amount_sat, + 'fees_sat': payment.fees_sat, + 'payment_type': str(payment.payment_type), # Convert Enum to string + 'status': str(payment.status), # Convert Enum to string + 'details': self.sdk_to_dict(payment.details) if payment.details else None, # Include details dict + 'destination': getattr(payment, 'destination', None), # Optional field + 'tx_id': getattr(payment, 'tx_id', None), # Optional field + 'payment_hash': getattr(payment.details, 'payment_hash', None), # Often useful, from details + 'swap_id': getattr(payment.details, 'swap_id', None), # Often useful, from details + } + payment_list.append(payment_dict) + + logger.debug(f"Listed {len(payment_list)} payments.") + logger.debug("Exiting list_payments") + return payment_list + + except Exception as e: + logger.error(f"Error listing payments: {e}") + logger.debug("Exiting list_payments (error)") + raise + + def get_payment(self, identifier: str, identifier_type: str = 'payment_hash') -> Optional[Dict[str, Any]]: + """ + Retrieves a specific payment by hash or swap ID. + + Args: + identifier: The payment hash or swap ID string. + identifier_type: 'payment_hash' or 'swap_id'. + Returns: + Payment dictionary or None if not found. + Raises: + ValueError: If invalid identifier_type is provided. + Exception: For any SDK errors. + """ + logger.debug(f"Entering get_payment with identifier: {identifier}, type: {identifier_type}") + try: + self.wait_for_sync() + req = None + if identifier_type == 'payment_hash': + req = GetPaymentRequest.PAYMENT_HASH(identifier) + elif identifier_type == 'swap_id': + req = GetPaymentRequest.SWAP_ID(identifier) + else: + logger.warning(f"Invalid identifier_type for get_payment: {identifier_type}") + raise ValueError("identifier_type must be 'payment_hash' or 'swap_id'") + + payment = self.instance.get_payment(req) + if payment: + # Use a helper function if payment-to-dict conversion is common + payment_dict = { + 'id': getattr(payment, 'id', None), + 'timestamp': payment.timestamp, + 'amount_sat': payment.amount_sat, + 'fees_sat': payment.fees_sat, + 'payment_type': str(payment.payment_type), + 'status': str(payment.status), + 'details': self.sdk_to_dict(payment.details) if payment.details else None, + 'destination': getattr(payment, 'destination', None), + 'tx_id': getattr(payment, 'tx_id', None), + 'payment_hash': getattr(payment.details, 'payment_hash', None), + 'swap_id': getattr(payment.details, 'swap_id', None), + } + logger.debug(f"Fetched payment: {identifier}") + logger.debug("Exiting get_payment (found)") + return payment_dict + else: + logger.debug(f"Payment not found: {identifier}") + logger.debug("Exiting get_payment (not found)") + return None + + except Exception as e: + logger.error(f"Error getting payment {identifier}: {e}") + logger.debug("Exiting get_payment (error)") + raise + + # --- Sending Payments --- + def send_payment(self, destination: str, amount_sat: Optional[int] = None, amount_asset: Optional[float] = None, asset_id: Optional[str] = None, drain: bool = False) -> Dict[str, Any]: + """ + Prepares and sends a payment to a destination (BOLT11, Liquid BIP21/address) + for Bitcoin or other Liquid assets. + + Args: + destination: The payment destination string. + amount_sat: Optional amount in satoshis for Bitcoin payments. + amount_asset: Optional amount for asset payments (as float). + asset_id: Required if amount_asset is provided. The asset ID string. + drain: If True, sends all funds (overrides amount arguments). + Returns: + Dictionary with initiated payment details. + Raises: + ValueError: If inconsistent or missing amount arguments. + Exception: For any SDK errors. + """ + logger.debug(f"Entering send_payment to {destination} (amount_sat={amount_sat}, amount_asset={amount_asset}, asset_id={asset_id}, drain={drain})") + try: + self.wait_for_sync() + amount_obj = None + + if drain: + amount_obj = PayAmount.DRAIN + logger.debug("Sending payment using DRAIN amount.") + elif amount_sat is not None: + if amount_asset is not None or asset_id is not None: + logger.warning("Conflicting amount arguments: amount_sat provided with asset arguments.") + raise ValueError("Provide either amount_sat, or (amount_asset and asset_id), or drain=True.") + amount_obj = PayAmount.BITCOIN(amount_sat) + logger.debug(f"Sending Bitcoin payment with amount: {amount_sat} sat.") + elif amount_asset is not None and asset_id is not None: + if amount_sat is not None or drain: + logger.warning("Conflicting amount arguments: asset arguments provided with amount_sat or drain.") + raise ValueError("Provide either amount_sat, or (amount_asset and asset_id), or drain=True.") + # False is 'is_liquid_fee' - typically false for standard asset sends + amount_obj = PayAmount.ASSET(asset_id, amount_asset, False) + logger.debug(f"Sending asset payment {asset_id} with amount: {amount_asset}.") + else: + logger.warning("Missing or inconsistent amount arguments.") + raise ValueError("Provide either amount_sat, or (amount_asset and asset_id), or drain=True.") + + + prepare_req = PrepareSendRequest(destination=destination, amount=amount_obj) + prepare_res = self.instance.prepare_send_payment(prepare_req) + + # You might want to add a step here to check fees and potentially ask for confirmation + logger.info(f"Prepared send payment to {destination}. Fees: {prepare_res.fees_sat} sat.") + logger.debug(f"PrepareSendRequest response: {prepare_res.__dict__}") + + + req = SendPaymentRequest(prepare_response=prepare_res) + send_res = self.instance.send_payment(req) + + # You can track the payment status via the listener or check_payment_status later + initiated_payment_details = { + 'status': str(send_res.payment.status), # Initial status (likely PENDING) + 'destination': getattr(send_res.payment, 'destination', None), # May or may not be present + 'fees_sat': prepare_res.fees_sat, # Prepared fees, final fees might differ slightly + 'payment_hash': getattr(send_res.payment.details, 'payment_hash', None), # Likely present for lightning + 'swap_id': getattr(send_res.payment.details, 'swap_id', None), # Likely present for onchain/liquid swaps + } + logger.info(f"Send payment initiated to {destination}.") + logger.debug(f"Send payment initiated details: {initiated_payment_details}") + logger.debug("Exiting send_payment (initiated)") + + return initiated_payment_details + + except Exception as e: + logger.error(f"Error sending payment to {destination}: {e}") + logger.debug("Exiting send_payment (error)") + raise + + # --- Receiving Payments --- + def receive_payment(self, amount: int, payment_method: str = 'LIGHTNING', description: Optional[str] = None, asset_id: Optional[str] = None) -> Dict[str, Any]: + """ + Prepares and generates a receive address/invoice. + + Args: + amount: The amount to receive. + payment_method: 'LIGHTNING', 'BITCOIN_ADDRESS', or 'LIQUID_ADDRESS'. + description: Optional description for the payment (mainly for Lightning). + asset_id: Optional asset ID string for receiving specific assets on Liquid. + Returns: + Dictionary with destination (address/invoice) and prepared fees. + Raises: + ValueError: If invalid payment_method is provided. + Exception: For any SDK errors. + """ + logger.debug(f"Entering receive_payment (amount={amount}, method={payment_method}, asset={asset_id})") + try: + method = getattr(PaymentMethod, payment_method.upper(), None) + if not method: + logger.warning(f"Invalid payment_method: {payment_method}") + raise ValueError(f"Invalid payment_method: {payment_method}. Must be 'LIGHTNING', 'BITCOIN_ADDRESS', or 'LIQUID_ADDRESS'.") + + if asset_id: + receive_amount_obj = ReceiveAmount.ASSET(asset_id, amount) + logger.debug(f"Receiving asset {asset_id} with amount {amount}") + else: + receive_amount_obj = ReceiveAmount.BITCOIN(amount) + logger.debug(f"Receiving Bitcoin with amount {amount} sat.") + + + prepare_req = PrepareReceiveRequest(payment_method=method, amount=receive_amount_obj) + prepare_res = self.instance.prepare_receive_payment(prepare_req) + + logger.info(f"Prepared receive payment ({payment_method}). Fees: {prepare_res.fees_sat} sat.") + logger.debug(f"PrepareReceiveRequest response: {prepare_res.__dict__}") + + + req = ReceivePaymentRequest(prepare_response=prepare_res, description=description) + receive_res = self.instance.receive_payment(req) + + logger.info(f"Receive payment destination generated: {receive_res.destination}") + logger.debug(f"Receive payment response: {receive_res.__dict__}") + logger.debug("Exiting receive_payment") + + + return { + 'destination': receive_res.destination, + 'fees_sat': prepare_res.fees_sat, # Prepared fees, final fees might differ + } + except Exception as e: + logger.error(f"Error receiving payment ({payment_method}) for amount {amount}: {e}") + logger.debug("Exiting receive_payment (error)") + raise + + # --- Buy Bitcoin --- + def fetch_buy_bitcoin_limits(self) -> Dict[str, Any]: + """ + Fetches limits for buying Bitcoin (uses onchain limits). + + Returns: + Dictionary containing receive and send limits. + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering fetch_buy_bitcoin_limits") + try: + self.wait_for_sync() + limits = self.instance.fetch_onchain_limits() # Onchain limits apply to Buy/Sell + limits_dict = { + 'receive': limits.receive.__dict__ if limits.receive else None, + 'send': limits.send.__dict__ if limits.send else None, + } + logger.debug(f"Fetched buy/sell limits successfully.") + logger.debug("Exiting fetch_buy_bitcoin_limits") + return limits_dict + except Exception as e: + logger.error(f"Error fetching buy bitcoin limits: {e}") + logger.debug("Exiting fetch_buy_bitcoin_limits (error)") + raise + + def prepare_buy_bitcoin(self, provider: str, amount_sat: int) -> Dict[str, Any]: + """ + Prepares a buy Bitcoin request. + + Args: + provider: The buy provider string (e.g., 'MOONPAY'). + amount_sat: The amount in satoshis to buy. + Returns: + Dictionary with preparation details, including fees. + Raises: + ValueError: If invalid provider is provided. + Exception: For any SDK errors. + """ + logger.debug(f"Entering prepare_buy_bitcoin (provider={provider}, amount={amount_sat})") + try: + self.wait_for_sync() + buy_provider = getattr(BuyBitcoinProvider, provider.upper(), None) + if not buy_provider: + logger.warning(f"Invalid buy bitcoin provider: {provider}") + raise ValueError(f"Invalid buy bitcoin provider: {provider}.") + + req = PrepareBuyBitcoinRequest(provider=buy_provider, amount_sat=amount_sat) + prepare_res = self.instance.prepare_buy_bitcoin(req) + prepare_res_dict = prepare_res.__dict__ + logger.info(f"Prepared buy bitcoin with {provider}. Fees: {prepare_res.fees_sat} sat.") + logger.debug(f"PrepareBuyBitcoinRequest response: {prepare_res_dict}") + logger.debug("Exiting prepare_buy_bitcoin") + + return prepare_res_dict + except Exception as e: + logger.error(f"Error preparing buy bitcoin for {amount_sat} with {provider}: {e}") + logger.debug("Exiting prepare_buy_bitcoin (error)") + raise + + # Refined signature to expect the SDK object + def buy_bitcoin(self, prepare_response: PrepareBuyBitcoinResponse) -> str: + """ + Executes a buy Bitcoin request using prepared data. + + Args: + prepare_response: The PrepareBuyBitcoinResponse object returned by prepare_buy_bitcoin. + Returns: + The URL string to complete the purchase. + Raises: + TypeError: If prepare_response is not the correct type. + Exception: For any SDK errors. + """ + logger.debug("Entering buy_bitcoin") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(prepare_response, PrepareBuyBitcoinResponse): + logger.error(f"buy_bitcoin expects PrepareBuyBitcoinResponse object, but received {type(prepare_response)}.") + raise TypeError("buy_bitcoin expects the SDK PrepareBuyBitcoinResponse object") + + req = BuyBitcoinRequest(prepare_response=prepare_response) # Pass the actual object + url = self.instance.buy_bitcoin(req) + logger.info(f"Buy bitcoin URL generated.") + logger.debug("Exiting buy_bitcoin") + return url + except Exception as e: + logger.error(f"Error executing buy bitcoin: {e}") + logger.debug("Exiting buy_bitcoin (error)") + raise + + # --- Fiat Currencies --- + def list_fiat_currencies(self) -> List[Dict[str, Any]]: + """ + Lists supported fiat currencies. + + Returns: + List of fiat currency dictionaries. + """ + logger.debug("Entering list_fiat_currencies") + try: + self.wait_for_sync() # Fiat data might need sync + currencies = self.instance.list_fiat_currencies() + currencies_list = [c.__dict__ for c in currencies] + logger.debug(f"Listed {len(currencies_list)} fiat currencies.") + logger.debug("Exiting list_fiat_currencies") + return currencies_list + except Exception as e: + logger.error(f"Error listing fiat currencies: {e}") + logger.debug("Exiting list_fiat_currencies (error)") + raise + + def fetch_fiat_rates(self) -> List[Dict[str, Any]]: + """ + Fetches current fiat exchange rates. + + Returns: + List of fiat rate dictionaries. + """ + logger.debug("Entering fetch_fiat_rates") + try: + self.wait_for_sync() # Fiat data might need sync + rates = self.instance.fetch_fiat_rates() + rates_list = [r.__dict__ for r in rates] + logger.debug(f"Fetched {len(rates_list)} fiat rates.") + logger.debug("Exiting fetch_fiat_rates") + return rates_list + except Exception as e: + logger.error(f"Error fetching fiat rates: {e}") + logger.debug("Exiting fetch_fiat_rates (error)") + raise + + # --- LNURL Operations --- + def parse_input(self, input_str: str) -> Dict[str, Any]: + """ + Parses various input types (LNURL, addresses, invoices, etc.). + + Args: + input_str: The string input to parse. + Returns: + Dictionary representing the parsed input details. + Raises: + Exception: For any SDK errors during parsing. + """ + logger.debug(f"Entering parse_input with input: {input_str}") + try: + self.wait_for_sync() # Parsing might require network interaction + parsed_input = self.instance.parse(input_str) + # Convert the specific InputType object to a dictionary + # Access .data on the *instance* of the parsed input, not the type + if isinstance(parsed_input, InputType.BITCOIN_ADDRESS): + result = {'type': 'BITCOIN_ADDRESS', 'address': parsed_input.address.address} + elif isinstance(parsed_input, InputType.BOLT11): + result = {'type': 'BOLT11', 'invoice': parsed_input.invoice.__dict__} + elif isinstance(parsed_input, InputType.LN_URL_PAY): + # Access data on the instance: parsed_input.data + result = {'type': 'LN_URL_PAY', 'data': parsed_input.data.__dict__} + elif isinstance(parsed_input, InputType.LN_URL_AUTH): + # Access data on the instance: parsed_input.data + result = {'type': 'LN_URL_AUTH', 'data': parsed_input.data.__dict__} + elif isinstance(parsed_input, InputType.LN_URL_WITHDRAW): + # Access data on the instance: parsed_input.data + result = {'type': 'LN_URL_WITHDRAW', 'data': parsed_input.data.__dict__} + elif isinstance(parsed_input, InputType.LIQUID_ADDRESS): + result = {'type': 'LIQUID_ADDRESS', 'address': parsed_input.address.address} + elif isinstance(parsed_input, InputType.BIP21): + result = {'type': 'BIP21', 'data': parsed_input.bip21.__dict__} + elif isinstance(parsed_input, InputType.NODE_ID): + result = {'type': 'NODE_ID', 'node_id': parsed_input.node_id} + else: + # Log raw data for unhandled types to aid debugging + logger.warning(f"Parsed unknown input type: {type(parsed_input)}") + result = {'type': 'UNKNOWN', 'raw_input': input_str, 'raw_parsed_object': str(parsed_input)} + + logger.debug(f"Parsed input successfully. Type: {result.get('type')}") + logger.debug("Exiting parse_input") + + return result + except Exception as e: + logger.error(f"Error parsing input '{input_str}': {e}") + logger.debug("Exiting parse_input (error)") + raise + + # Corrected type hint to LnUrlPayRequestData + def prepare_lnurl_pay(self, data: LnUrlPayRequestData, amount_sat: int, comment: Optional[str] = None, validate_success_action_url: bool = True) -> Dict[str, Any]: + """ + Prepares an LNURL-Pay request. + + Args: + data: The LnUrlPayRequestData object from a parsed LNURL_PAY input's .data attribute. + amount_sat: Amount in satoshis. + comment: Optional comment. + validate_success_action_url: Whether to validate the success action URL. + Returns: + Dictionary with preparation details. + Raises: + TypeError: If data is not the correct object type. + Exception: For any SDK errors. + """ + logger.debug(f"Entering prepare_lnurl_pay (amount={amount_sat}, comment={comment})") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(data, LnUrlPayRequestData): + logger.error(f"prepare_lnurl_pay expects LnUrlPayRequestData object, but received {type(data)}.") + raise TypeError("prepare_lnurl_pay expects the SDK LnUrlPayRequestData object") + + + # Handle amount format for PayAmount + pay_amount = PayAmount.BITCOIN(amount_sat) + + req = PrepareLnUrlPayRequest( + data=data, # Use the passed object + amount=pay_amount, + comment=comment, + validate_success_action_url=validate_success_action_url, + bip353_address=getattr(data, 'bip353_address', None) # Get bip353_address from the object + ) + prepare_res = self.instance.prepare_lnurl_pay(req) + prepare_res_dict = prepare_res.__dict__ + logger.info(f"Prepared LNURL-Pay. Fees: {prepare_res.fees_sat} sat.") + logger.debug(f"PrepareLnUrlPayRequest response: {prepare_res_dict}") + logger.debug("Exiting prepare_lnurl_pay") + + return prepare_res_dict + except Exception as e: + logger.error(f"Error preparing LNURL-Pay: {e}") + logger.debug("Exiting prepare_lnurl_pay (error)") + raise + + # Refined signature to expect the SDK object + def lnurl_pay(self, prepare_response: PrepareLnUrlPayResponse) -> Optional[Dict[str, Any]]: + """ + Executes an LNURL-Pay payment using prepared data. + + Args: + prepare_response: The PrepareLnUrlPayResponse object returned by prepare_lnurl_pay. + Returns: + Dictionary with payment result details, or None if no specific result. + Raises: + TypeError: If prepare_response is not the correct type. + Exception: For any SDK errors. + """ + logger.debug("Entering lnurl_pay") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(prepare_response, PrepareLnUrlPayResponse): + logger.error(f"lnurl_pay expects PrepareLnUrlPayResponse object, but received {type(prepare_response)}.") + raise TypeError("lnurl_pay expects the SDK PrepareLnUrlPayResponse object") + + req = LnUrlPayRequest(prepare_response=prepare_response) # Pass the actual object + result = self.instance.lnurl_pay(req) + result_dict = result.__dict__ if result else None # Result type depends on success action + logger.info("Executed LNURL-Pay.") + logger.debug(f"LNURL-Pay result: {result_dict}") + logger.debug("Exiting lnurl_pay") + return result_dict + except Exception as e: + logger.error(f"Error executing LNURL-Pay: {e}") + logger.debug("Exiting lnurl_pay (error)") + raise + + # Corrected type hint to LnUrlAuthRequestData + def lnurl_auth(self, data: LnUrlAuthRequestData) -> bool: + """ + Performs LNURL-Auth. + + Args: + data: The LnUrlAuthRequestData object from a parsed LNURL_AUTH input's .data attribute. + Returns: + True if authentication was successful, False otherwise. + Raises: + TypeError: If data is not the correct object type. + Exception: For any SDK errors. + """ + logger.debug("Entering lnurl_auth") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(data, LnUrlAuthRequestData): + logger.error(f"lnurl_auth expects LnUrlAuthRequestData object, but received {type(data)}.") + raise TypeError("lnurl_auth expects the SDK LnUrlAuthRequestData object") + + result = self.instance.lnurl_auth(data) # Pass the actual object + is_ok = result.is_ok() + if is_ok: + logger.info("LNURL-Auth successful.") + else: + # Log the error message from the result if available + error_msg = getattr(result, 'error', 'Unknown error') + logger.warning(f"LNURL-Auth failed. Error: {error_msg}") + logger.debug(f"LNURL-Auth result: {is_ok}") + logger.debug("Exiting lnurl_auth") + return is_ok + except Exception as e: + logger.error(f"Error performing LNURL-Auth: {e}") + logger.debug("Exiting lnurl_auth (error)") + raise + + # Corrected type hint to LnurlWithdrawRequestData + def lnurl_withdraw(self, data: LnUrlWithdrawRequestData, amount_msat: int, comment: Optional[str] = None) -> Optional[Dict[str, Any]]: + """ + Performs LNURL-Withdraw. + + Args: + data: The LnUrlWithdrawRequestData object from a parsed LNURL_WITHDRAW input's .data attribute. + amount_msat: Amount in millisatoshis to withdraw. + comment: Optional comment string. + Returns: + Dictionary with withdrawal result details, or None if no specific result. + Raises: + TypeError: If data is not the correct object type. + Exception: For any SDK errors. + """ + logger.debug(f"Entering lnurl_withdraw (amount_msat={amount_msat}, comment={comment})") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(data, LnUrlWithdrawRequestData): + logger.error(f"lnurl_withdraw expects LnUrlWithdrawRequestData object, but received {type(data)}.") + raise TypeError("lnurl_withdraw expects the SDK LnUrlWithdrawRequestData object") + + # Basic validation for amount and comment + if not isinstance(amount_msat, int) or amount_msat <= 0: + logger.warning(f"Invalid amount_msat provided: {amount_msat}") + raise ValueError("amount_msat must be a positive integer.") + if comment is not None and not isinstance(comment, str): + logger.warning(f"Invalid comment type provided: {type(comment)}") + raise ValueError("comment must be a string or None.") + + result = self.instance.lnurl_withdraw(data, amount_msat, comment) # Pass the actual object + result_dict = result.__dict__ if result else None # Check result type + logger.info("Executed LNURL-Withdraw.") + logger.debug(f"LNURL-Withdraw result: {result_dict}") + logger.debug("Exiting lnurl_withdraw") + return result_dict + except Exception as e: + logger.error(f"Error executing LNURL-Withdraw: {e}") + logger.debug("Exiting lnurl_withdraw (error)") + raise + + # --- Onchain Operations --- + # fetch_pay_onchain_limits is covered by fetch_onchain_limits (public method) + + def prepare_pay_onchain(self, amount_sat: Optional[int] = None, drain: bool = False, fee_rate_sat_per_vbyte: Optional[int] = None) -> Dict[str, Any]: + """ + Prepares an onchain payment (Bitcoin address). + + Args: + amount_sat: Optional amount in satoshis (required unless drain is True). + drain: If True, prepares to send all funds. + fee_rate_sat_per_vbyte: Optional custom fee rate. + Returns: + Dictionary with preparation details. + Raises: + ValueError: If amount is missing for non-drain payment. + Exception: For any SDK errors. + """ + logger.debug(f"Entering prepare_pay_onchain (amount={amount_sat}, drain={drain}, fee_rate={fee_rate_sat_per_vbyte})") + try: + # Determine amount object based on inputs + if drain: + amount_obj = PayAmount.DRAIN + logger.debug("Preparing onchain payment using DRAIN amount.") + elif amount_sat is not None: + amount_obj = PayAmount.BITCOIN(amount_sat) + logger.debug(f"Preparing onchain payment with amount: {amount_sat} sat.") + else: + logger.warning("Amount is missing for non-drain pay onchain.") + raise ValueError("Amount must be provided for non-drain payments.") + + # Optional fee rate validation + if fee_rate_sat_per_vbyte is not None and (not isinstance(fee_rate_sat_per_vbyte, int) or fee_rate_sat_per_vbyte <= 0): + logger.warning(f"Invalid fee_rate_sat_per_vbyte provided: {fee_rate_sat_per_vbyte}") + raise ValueError("fee_rate_sat_per_vbyte must be a positive integer or None.") + + + req = PreparePayOnchainRequest(amount=amount_obj, fee_rate_sat_per_vbyte=fee_rate_sat_per_vbyte) + prepare_res = self.instance.prepare_pay_onchain(req) + prepare_res_dict = prepare_res.__dict__ + logger.info(f"Prepared pay onchain. Total fees: {prepare_res.total_fees_sat} sat.") + logger.debug(f"PreparePayOnchainRequest response: {prepare_res_dict}") + logger.debug("Exiting prepare_pay_onchain") + return prepare_res_dict + except Exception as e: + logger.error(f"Error preparing pay onchain: {e}") + logger.debug("Exiting prepare_pay_onchain (error)") + raise + + # Refined signature to expect the SDK object + def pay_onchain(self, address: str, prepare_response: PreparePayOnchainResponse): + """ + Executes an onchain payment using prepared data. + + Args: + address: The destination Bitcoin address string. + prepare_response: The PreparePayOnchainResponse object returned by prepare_pay_onchain. + Raises: + TypeError: If prepare_response is not the correct type. + ValueError: If address is invalid. + Exception: For any SDK errors. + """ + logger.debug(f"Entering pay_onchain to {address}") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(prepare_response, PreparePayOnchainResponse): + logger.error(f"pay_onchain expects PreparePayOnchainResponse object, but received {type(prepare_response)}.") + raise TypeError("pay_onchain expects the SDK PreparePayOnchainResponse object") + + # Basic check for address format (could add more robust validation) + if not isinstance(address, str) or not address: + logger.warning("Invalid or empty destination address provided for pay_onchain.") + raise ValueError("Destination address must be a non-empty string.") + + + req = PayOnchainRequest(address=address, prepare_response=prepare_response) # Pass the actual object + self.instance.pay_onchain(req) + logger.info(f"Onchain payment initiated to {address}.") + logger.debug("Exiting pay_onchain") + + # Note: Onchain payments might not trigger an immediate SDK event like lightning payments + # You might need to poll list_payments or rely on webhooks to track final status. + + except Exception as e: + logger.error(f"Error executing pay onchain to {address}: {e}") + logger.debug("Exiting pay_onchain (error)") + raise + + # list_refundable_payments method (already present, returns list of RefundableSwap objects) + def list_refundable_payments(self) -> List[RefundableSwap]: + """ + Lists refundable onchain swaps. + + Returns: + List of RefundableSwap objects. + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering list_refundable_payments") + try: + self.wait_for_sync() # Ensure sync before executing + refundable_payments = self.instance.list_refundables() + logger.debug(f"Found {len(refundable_payments)} refundable payments.") + logger.debug("Exiting list_refundable_payments") + return refundable_payments # Return the list of objects directly + + except Exception as e: + logger.error(f"Error listing refundable payments: {e}") + logger.debug("Exiting list_refundable_payments (error)") + raise + + # Updated signature and type hint to RefundableSwap and explicit refund_address + def execute_refund(self, refundable_swap: RefundableSwap, refund_address: str, fee_rate_sat_per_vbyte: int): + """ + Executes a refund for a refundable swap. + + Args: + refundable_swap: The RefundableSwap object to refund. + refund_address: The destination address string for the refund. + fee_rate_sat_per_vbyte: The desired fee rate in satoshis per vbyte for the refund transaction. + Raises: + TypeError: If refundable_swap is not the correct type. + ValueError: If refund_address or fee_rate_sat_per_vbyte is invalid. + Exception: For any SDK errors. + """ + # Using getattr with a default for logging in case refundable_swap is None or malformed (though type hint should prevent this) + logger.debug(f"Entering execute_refund for swap {getattr(refundable_swap, 'swap_address', 'N/A')} to {refund_address} with fee rate {fee_rate_sat_per_vbyte}") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(refundable_swap, RefundableSwap): + logger.error(f"execute_refund expects RefundableSwap object, but received {type(refundable_swap)}.") + raise TypeError("execute_refund expects the SDK RefundableSwap object") + + # Basic check for refund_address format (could add more robust validation) + if not isinstance(refund_address, str) or not refund_address: + logger.warning("Invalid or empty refund_address provided for execute_refund.") + raise ValueError("Refund destination address must be a non-empty string.") + + if not isinstance(fee_rate_sat_per_vbyte, int) or fee_rate_sat_per_vbyte <= 0: + logger.warning(f"Invalid fee_rate_sat_per_vbyte provided: {fee_rate_sat_per_vbyte}") + raise ValueError("fee_rate_sat_per_vbyte must be a positive integer.") + + + req = RefundRequest( + swap_address=refundable_swap.swap_address, # Use address from the object + refund_address=refund_address, + fee_rate_sat_per_vbyte=fee_rate_sat_per_vbyte + ) + self.instance.refund(req) + logger.info(f"Refund initiated for swap {refundable_swap.swap_address} to {refund_address}.") + logger.debug("Exiting execute_refund") + + # Note: Onchain refunds might not trigger an immediate SDK event + # You might need to poll list_payments or rely on webhooks to track final status. + + + except Exception as e: + logger.error(f"Error executing refund for swap {getattr(refundable_swap, 'swap_address', 'N/A')}: {e}") + logger.debug("Exiting execute_refund (error)") + raise + + # rescan_swaps method (already present) + def rescan_swaps(self): + """ + Rescans onchain swaps. + + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering rescan_swaps") + try: + self.wait_for_sync() # Ensure sync before executing + self.instance.rescan_onchain_swaps() + logger.info("Onchain swaps rescan initiated.") + logger.debug("Exiting rescan_swaps") + + except Exception as e: + logger.error(f"Error rescanning swaps: {e}") + logger.debug("Exiting rescan_swaps (error)") + raise + + def recommended_fees(self) -> Dict[str, int]: + """ + Fetches recommended transaction fees. + + Returns: + Dictionary with fee rate estimates (e.g., {'fastest': 100, 'half_hour': 50, ...}). + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering recommended_fees") + try: + self.wait_for_sync() # Fee estimates might need network + fees = self.instance.recommended_fees() + # Assuming recommended_fees returns an object with __dict__ or similar fee structure + fees_dict = fees.__dict__ if fees else {} # Convert to dict + logger.debug(f"Fetched recommended fees: {fees_dict}") + logger.debug("Exiting recommended_fees") + return fees_dict + except Exception as e: + logger.error(f"Error fetching recommended fees: {e}") + logger.debug("Exiting recommended_fees (error)") + raise + + def handle_payments_waiting_fee_acceptance(self): + """ + Fetches and automatically accepts payments waiting for fee acceptance. + In a real app, you would add logic to decide whether to accept the fees. + + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering handle_payments_waiting_fee_acceptance") + try: + self.wait_for_sync() + logger.info("Checking for payments waiting for fee acceptance...") + # Filter for WAITING_FEE_ACCEPTANCE state + payments_waiting = self.instance.list_payments( + ListPaymentsRequest(states=[PaymentState.WAITING_FEE_ACCEPTANCE]) + ) + + handled_count = 0 + for payment in payments_waiting: + # Double-check payment type and swap_id as per doc example + if not isinstance(payment.details, PaymentDetails.BITCOIN) or not payment.details.swap_id: + logger.warning(f"Skipping payment in WAITING_FEE_ACCEPTANCE state without Bitcoin details or swap_id: {getattr(payment, 'destination', 'N/A')}") + continue + + swap_id = payment.details.swap_id + logger.info(f"Found payment waiting fee acceptance: {getattr(payment, 'destination', 'N/A')} (Swap ID: {swap_id})") + + fetch_fees_req = FetchPaymentProposedFeesRequest(swap_id=swap_id) + fetch_fees_response = self.instance.fetch_payment_proposed_fees(fetch_fees_req) + + logger.info( + f"Payer sent {fetch_fees_response.payer_amount_sat} " + f"and currently proposed fees are {fetch_fees_response.fees_sat}" + ) + + # --- Decision Point: Accept Fees? --- + # In a real application, you would implement logic here to decide if the proposed fees + # are acceptable based on your application's criteria. + # For this example, we will automatically accept. + logger.info(f"Automatically accepting proposed fees for swap {swap_id}.") + # --- End Decision Point --- + + accept_fees_req = AcceptPaymentProposedFeesRequest(response=fetch_fees_response) + self.instance.accept_payment_proposed_fees(accept_fees_req) + logger.info(f"Accepted proposed fees for swap {swap_id}.") + handled_count += 1 + + logger.info(f"Finished checking for payments waiting fee acceptance. Handled {handled_count}.") + logger.debug("Exiting handle_payments_waiting_fee_acceptance") + + except Exception as e: + logger.error(f"Error handling payments waiting fee acceptance: {e}") + logger.debug("Exiting handle_payments_waiting_fee_acceptance (error)") + raise + + + # --- Working with Non-Bitcoin Assets --- + # Asset Metadata configuration is done in __init__ + + # prepare_receive_asset is covered by receive_payment with asset_id parameter + + # prepare_send_payment_asset is covered by the updated send_payment with asset_id parameter + + def fetch_asset_balance(self) -> Dict[str, Any]: + """ + Fetches the balance of all assets (Bitcoin and others). + Note: This information is part of get_info(). + + Returns: + Dictionary containing asset balances. + Raises: + Exception: For any SDK errors from get_info. + """ + logger.debug("Entering fetch_asset_balance") + try: + # This information is part of get_info().wallet_info.asset_balances + # Calling get_info handles sync and error logging + info = self.get_info() + # Extract asset_balances from the returned info dictionary + asset_balances = info.get('wallet_info', {}).get('asset_balances', {}) + + # The asset_balances value is a list of AssetBalance objects. + # You might want to convert these to dictionaries too for consistency if needed. + # For now, returning the list of objects as is fetched by get_info. + # If conversion is needed: + # converted_balances = [bal.__dict__ for bal in asset_balances] + + logger.debug(f"Fetched asset balances: {asset_balances}") + logger.debug("Exiting fetch_asset_balance") + return asset_balances # Or return converted_balances + + except Exception as e: + # get_info already logs, this catch is mainly to ensure debug exit logging + # If get_info fails, it raises, so this block might not be reached + logger.error(f"Error fetching asset balance (via get_info): {e}") + logger.debug("Exiting fetch_asset_balance (error)") + raise + + + # --- Webhook Management --- + def register_webhook(self, webhook_url: str): + """ + Registers a webhook URL for receiving notifications. + + Args: + webhook_url: The URL string to register. + Raises: + ValueError: If webhook_url is invalid. + Exception: For any SDK errors. + """ + logger.debug(f"Entering register_webhook with URL: {webhook_url}") + try: + self.wait_for_sync() # Might require network + # Basic URL format validation (can be made more robust) + if not isinstance(webhook_url, str) or not webhook_url.startswith('https://'): + logger.warning(f"Invalid webhook_url provided: {webhook_url}") + raise ValueError("Webhook URL must be a valid HTTPS URL.") + + self.instance.register_webhook(webhook_url) + logger.info(f"Webhook registered: {webhook_url}") + logger.debug("Exiting register_webhook") + except Exception as e: + logger.error(f"Error registering webhook {webhook_url}: {e}") + logger.debug("Exiting register_webhook (error)") + raise + + def unregister_webhook(self): + """ + Unregisters the currently registered webhook. + + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering unregister_webhook") + try: + self.wait_for_sync() # Might require network + self.instance.unregister_webhook() + logger.info("Webhook unregistered.") + logger.debug("Exiting unregister_webhook") + except Exception as e: + logger.error(f"Error unregistering webhook: {e}") + logger.debug("Exiting unregister_webhook (error)") + raise + + # --- Utilities and Message Signing --- + # parse_input is implemented above + + def sign_message(self, message: str) -> Dict[str, str]: + """ + Signs a message with the wallet's key. + + Args: + message: The message string to sign. + Returns: + Dictionary with the signature and the wallet's public key string. + Raises: + ValueError: If message is invalid. + Exception: For any SDK errors. + """ + # Log truncated message to avoid logging potentially sensitive full messages + logger.debug(f"Entering sign_message with message (truncated): {message[:50]}...") + try: + self.wait_for_sync() + if not isinstance(message, str) or not message: + logger.warning("Invalid or empty message provided for signing.") + raise ValueError("Message to sign must be a non-empty string.") + + req = SignMessageRequest(message=message) + sign_res = self.instance.sign_message(req) + # Fetch info AFTER signing to get the pubkey that was used + info = self.instance.get_info() + + pubkey = info.wallet_info.pubkey if info and info.wallet_info else None + + if not pubkey: + logger.warning("Could not retrieve wallet pubkey after signing message.") + # Decide how to handle this - return None for pubkey or raise error + # Returning None for pubkey might be acceptable, the signature is the main result. + pass + + + result = { + 'signature': sign_res.signature, + 'pubkey': pubkey, + } + logger.info("Message signed.") + logger.debug("Exiting sign_message") + return result + except Exception as e: + logger.error(f"Error signing message: {e}") + logger.debug("Exiting sign_message (error)") + raise + + def check_message(self, message: str, pubkey: str, signature: str) -> bool: + """ + Verifies a signature against a message and public key. + + Args: + message: The original message string. + pubkey: The public key string used for signing. + signature: The signature string to verify. + Returns: + True if the signature is valid, False otherwise. + Raises: + ValueError: If message, pubkey, or signature are invalid. + Exception: For any SDK errors. + """ + logger.debug(f"Entering check_message for message (truncated): {message[:50]}...") + try: + self.wait_for_sync() # Might require network to verify + if not isinstance(message, str) or not message: + logger.warning("Invalid or empty message provided for checking.") + raise ValueError("Message to check must be a non-empty string.") + if not isinstance(pubkey, str) or not pubkey: + logger.warning("Invalid or empty pubkey provided for checking.") + raise ValueError("Pubkey must be a non-empty string.") + if not isinstance(signature, str) or not signature: + logger.warning("Invalid or empty signature provided for checking.") + raise ValueError("Signature must be a non-empty string.") + + + req = CheckMessageRequest(message=message, pubkey=pubkey, signature=signature) + check_res = self.instance.check_message(req) + is_valid = check_res.is_valid + logger.info(f"Message signature check result: {is_valid}") + logger.debug("Exiting check_message") + return is_valid + except Exception as e: + logger.error(f"Error checking message signature: {e}") + logger.debug("Exiting check_message (error)") + raise + + # External Input Parser configuration is done in __init__ + + # Payment Limits + # Keeping the explicit fetch methods as they are clearer + + def fetch_lightning_limits(self) -> Dict[str, Any]: + """ + Fetches current Lightning payment limits. + + Returns: + Dictionary containing receive and send limits. + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering fetch_lightning_limits") + try: + self.wait_for_sync() + limits = self.instance.fetch_lightning_limits() + limits_dict = { + 'receive': limits.receive.__dict__ if limits.receive else None, + 'send': limits.send.__dict__ if limits.send else None, + } + logger.debug(f"Fetched lightning limits: {limits_dict}") + logger.debug("Exiting fetch_lightning_limits") + return limits_dict + except Exception as e: + logger.error(f"Error fetching lightning limits: {e}") + logger.debug("Exiting fetch_lightning_limits (error)") + raise + + def fetch_onchain_limits(self) -> Dict[str, Any]: + """ + Fetches current onchain payment limits (used for Bitcoin send/receive). + + Returns: + Dictionary containing receive and send limits. + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering fetch_onchain_limits") + try: + self.wait_for_sync() + limits = self.instance.fetch_onchain_limits() + limits_dict = { + 'receive': limits.receive.__dict__ if limits.receive else None, + 'send': limits.send.__dict__ if limits.send else None, + } + logger.debug(f"Fetched onchain limits: {limits_dict}") + logger.debug("Exiting fetch_onchain_limits") + return limits_dict + except Exception as e: + logger.error(f"Error fetching onchain limits: {e}") + logger.debug("Exiting fetch_onchain_limits (error)") + raise + + def sdk_to_dict(self, obj): + if isinstance(obj, (str, int, float, bool, type(None))): + return obj + if isinstance(obj, list): + return [self.sdk_to_dict(i) for i in obj] + if hasattr(obj, '__dict__'): + return {k: self.sdk_to_dict(v) for k, v in obj.__dict__.items()} + return str(obj) # fallback \ No newline at end of file From d53aab8c2ec326ed98cce3d141bda88dd8841abf Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Fri, 16 May 2025 10:53:37 +0200 Subject: [PATCH 3/5] api updates --- fly/main.py | 56 +- fly/nodeless.py | 1487 ++++++++++++++++++++++++++++++++++++++++++ fly/requirements.txt | 4 + 3 files changed, 1544 insertions(+), 3 deletions(-) create mode 100644 fly/nodeless.py create mode 100644 fly/requirements.txt diff --git a/fly/main.py b/fly/main.py index 13525fa..abd7b5d 100644 --- a/fly/main.py +++ b/fly/main.py @@ -6,8 +6,13 @@ import os from dotenv import load_dotenv from enum import Enum from nodeless import PaymentHandler +from shopify.router import router as shopify_router +import logging + +# Set up logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) -# Load environment variables load_dotenv() app = FastAPI( @@ -20,9 +25,9 @@ API_KEY_NAME = "x-api-key" api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) API_KEY = os.getenv("API_SECRET") -from fastapi import APIRouter - +# Load environment variables ln_router = APIRouter(prefix="/v1/lnurl", tags=["lnurl"]) +app.include_router(shopify_router) # --- Models --- class PaymentMethodEnum(str, Enum): @@ -80,6 +85,14 @@ class SendOnchainResponse(BaseModel): address: str fees_sat: Optional[int] = None +class PaymentStatusResponse(BaseModel): + status: str + amount_sat: Optional[int] = None + fees_sat: Optional[int] = None + payment_time: Optional[int] = None + payment_hash: Optional[str] = None + error: Optional[str] = None + # LNURL Models class ParseInputBody(BaseModel): input: str @@ -212,6 +225,43 @@ async def onchain_limits( async def health(): return {"status": "ok"} +@app.get("/check_payment_status/{destination}", response_model=PaymentStatusResponse) +async def check_payment_status( + destination: str, + api_key: str = Depends(get_api_key), + handler: PaymentHandler = Depends(get_payment_handler) +): + """ + Check the status of a payment by its destination/invoice. + + Args: + destination: The payment destination (invoice) to check + Returns: + Payment status information including status, amount, fees, and timestamps + """ + logger.info(f"Received payment status check request for destination: {destination[:30]}...") + try: + logger.debug("Initializing PaymentHandler...") + logger.debug(f"Handler instance: {handler}") + logger.debug("Calling check_payment_status method...") + result = handler.check_payment_status(destination) + logger.info(f"Payment status check successful. Status: {result.get('status', 'unknown')}") + logger.debug(f"Full result: {result}") + return result + except ValueError as e: + logger.error(f"Validation error in check_payment_status: {str(e)}") + logger.exception("Validation error details:") + raise HTTPException(status_code=400, detail=str(e)) + except AttributeError as e: + logger.error(f"Attribute error in check_payment_status: {str(e)}") + logger.error(f"Handler methods: {dir(handler)}") + logger.exception("Attribute error details:") + raise HTTPException(status_code=500, detail=f"Server configuration error: {str(e)}") + except Exception as e: + logger.error(f"Unexpected error in check_payment_status: {str(e)}") + logger.exception("Full error details:") + raise HTTPException(status_code=500, detail=str(e)) + @ln_router.post("/parse_input") async def parse_input( request: ParseInputBody, diff --git a/fly/nodeless.py b/fly/nodeless.py new file mode 100644 index 0000000..ad7bc92 --- /dev/null +++ b/fly/nodeless.py @@ -0,0 +1,1487 @@ +import json +import os +import argparse +from typing import Optional, List, Dict, Any +from dotenv import load_dotenv +from breez_sdk_liquid import ( + LiquidNetwork, + PayAmount, + ConnectRequest, + PrepareSendRequest, + SendPaymentRequest, + PrepareReceiveRequest, + ReceivePaymentRequest, + EventListener, + SdkEvent, + connect, + default_config, + PaymentMethod, + ListPaymentsRequest, + InputType, # Added for parse functionality + SignMessageRequest, # Added for message signing + CheckMessageRequest, # Added for message checking + BuyBitcoinProvider, # Added for buy bitcoin + PrepareBuyBitcoinRequest, # Added for buy bitcoin + BuyBitcoinRequest, # Added for buy bitcoin + PreparePayOnchainRequest, # Added for pay onchain + PayOnchainRequest, # Added for pay onchain + RefundRequest, # Added for refunds + RefundableSwap, # Added for refunds + FetchPaymentProposedFeesRequest, # Added for fee acceptance + AcceptPaymentProposedFeesRequest, # Added for fee acceptance + PaymentState, # Added for fee acceptance + PaymentDetails, # Added for fee acceptance + AssetMetadata, # Added for assets + ExternalInputParser, # Added for parsers + GetPaymentRequest, # Added for get payment + ListPaymentDetails, # Added for list payments by details + ReceiveAmount, # Ensure ReceiveAmount is in this list! + + # --- Imports for refined method signatures --- + PrepareBuyBitcoinResponse, + PrepareLnUrlPayResponse, + PreparePayOnchainResponse, + # Correct Imports for LNURL Data Objects + LnUrlPayRequestData, # Corrected import for prepare_lnurl_pay + LnUrlAuthRequestData, # Corrected import for lnurl_auth + LnUrlWithdrawRequestData, # Corrected import for lnurl_withdraw + # RefundableSwap already imported + # --- End imports --- +) +import time +import logging +from pprint import pprint + +# Set up logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +class SdkListener(EventListener): + """ + A listener class for handling Breez SDK events. + + This class extends the EventListener from breez_sdk_liquid and implements + custom event handling logic, particularly for tracking successful payments + and other key SDK events. + """ + def __init__(self): + self.synced = False + self.paid = [] + self.refunded = [] # Added for tracking refunds + self.payment_statuses = {} # Track statuses for better payment checking + + def on_event(self, event): + """Handles incoming SDK events.""" + # Log all events at debug level + logger.debug(f"Received SDK event: {event}") + + if isinstance(event, SdkEvent.SYNCED): + self.synced = True + logger.info("SDK SYNCED") + elif isinstance(event, SdkEvent.PAYMENT_SUCCEEDED): + details = event.details + # Determine identifier based on payment type + identifier = None + if hasattr(details, 'destination') and details.destination: + identifier = details.destination + elif hasattr(details, 'payment_hash') and details.payment_hash: + identifier = details.payment_hash + + if identifier: + # Avoid duplicates if the same identifier can be seen multiple times + if identifier not in self.paid: + self.paid.append(identifier) + self.payment_statuses[identifier] = 'SUCCEEDED' + logger.info(f"PAYMENT SUCCEEDED for identifier: {identifier}") + else: + logger.info("PAYMENT SUCCEEDED with no clear identifier.") + + logger.debug(f"Payment Succeeded Details: {details}") # Log full details at debug + + elif isinstance(event, SdkEvent.PAYMENT_FAILED): + details = event.details + # Determine identifier based on payment type + identifier = None + if hasattr(details, 'destination') and details.destination: + identifier = details.destination + elif hasattr(details, 'payment_hash') and details.payment_hash: + identifier = details.payment_hash + elif hasattr(details, 'swap_id') and details.swap_id: + identifier = details.swap_id # Add swap_id as potential identifier + + error = getattr(details, 'error', 'Unknown error') + + if identifier: + self.payment_statuses[identifier] = 'FAILED' + logger.error(f"PAYMENT FAILED for identifier: {identifier}, Error: {error}") + else: + logger.error(f"PAYMENT FAILED with no clear identifier. Error: {error}") + + logger.debug(f"Payment Failed Details: {details}") # Log full details at debug + + def is_paid(self, destination: str) -> bool: + """Checks if a payment to a specific destination has succeeded.""" + # Check both the old list and the status dictionary + return destination in self.paid or self.payment_statuses.get(destination) == 'SUCCEEDED' + + def is_synced(self) -> bool: + """Checks if the SDK is synced.""" + return self.synced + + def get_payment_status(self, identifier: str) -> Optional[str]: + """ + Get the known status for a payment identified by destination, hash, or swap ID. + Returns status string ('SUCCEEDED', 'FAILED', 'REFUNDED', 'PENDING', etc.) or None. + """ + return self.payment_statuses.get(identifier) + + +class PaymentHandler: + """ + A wrapper class for the Breez SDK Nodeless (Liquid implementation). + + This class handles SDK initialization, connection, and provides simplified + methods for common payment and wallet operations. + """ + def __init__(self, network: LiquidNetwork = LiquidNetwork.MAINNET, working_dir: str = '~/.breez-cli', asset_metadata: Optional[List[AssetMetadata]] = None, external_input_parsers: Optional[List[ExternalInputParser]] = None): + """ + Initializes the PaymentHandler and connects to the Breez SDK. + + Args: + network: The Liquid network to use (MAINNET or TESTNET). + working_dir: The directory for SDK files. + asset_metadata: Optional list of AssetMetadata for non-Bitcoin assets. + external_input_parsers: Optional list of ExternalInputParser for custom input parsing. + """ + logger.debug("Entering PaymentHandler.__init__") + load_dotenv() # Load environment variables from .env + + self.breez_api_key = os.getenv('BREEZ_API_KEY') + self.seed_phrase = os.getenv('BREEZ_SEED_PHRASE') + + if not self.breez_api_key: + logger.error("BREEZ_API_KEY not found in environment variables.") + raise Exception("Missing Breez API key in .env file or environment") + if not self.seed_phrase: + logger.error("BREEZ_SEED_PHRASE not found in environment variables.") + raise Exception("Missing seed phrase in .env file or environment") + + logger.info("Retrieved credentials from environment successfully") + + config = default_config(network, self.breez_api_key) + # Expand user path for working_dir + config.working_dir = os.path.expanduser(working_dir) + # Ensure working directory exists + try: + os.makedirs(config.working_dir, exist_ok=True) + logger.debug(f"Ensured working directory exists: {config.working_dir}") + except OSError as e: + logger.error(f"Failed to create working directory {config.working_dir}: {e}") + raise # Re-raise if directory creation fails + + if asset_metadata: + config.asset_metadata = asset_metadata + logger.info(f"Configured asset metadata: {asset_metadata}") + + if external_input_parsers: + config.external_input_parsers = external_input_parsers + logger.info(f"Configured external input parsers: {external_input_parsers}") + + connect_request = ConnectRequest(config=config, mnemonic=self.seed_phrase) + + try: + self.instance = connect(connect_request) + self.listener = SdkListener() + # Add listener immediately after connecting + self.instance.add_event_listener(self.listener) + logger.info("Breez SDK connected successfully.") + except Exception as e: + logger.error(f"Failed to connect to Breez SDK: {e}") + # Re-raise the exception after logging + raise + + logger.debug("Exiting PaymentHandler.__init__") + + + def wait_for_sync(self, timeout_seconds: int = 30): + """Wait for the SDK to sync before proceeding.""" + logger.debug(f"Entering wait_for_sync (timeout={timeout_seconds}s)") + start_time = time.time() + while time.time() - start_time < timeout_seconds: + if self.listener.is_synced(): + logger.debug("SDK synced.") + logger.debug("Exiting wait_for_sync (synced)") + return True + time.sleep(0.5) # Shorter sleep for faster sync detection + logger.error("Sync timeout: SDK did not sync within the allocated time.") + logger.debug("Exiting wait_for_sync (timeout)") + raise Exception(f"Sync timeout: SDK did not sync within {timeout_seconds} seconds.") + + def wait_for_payment(self, identifier: str, timeout_seconds: int = 60) -> bool: + """ + Wait for payment to complete or timeout for a specific identifier + (destination, hash, or swap ID). + """ + logger.debug(f"Entering wait_for_payment (identifier={identifier}, timeout={timeout_seconds}s)") + start_time = time.time() + while time.time() - start_time < timeout_seconds: + status = self.listener.get_payment_status(identifier) + if status == 'SUCCEEDED': + logger.debug(f"Payment for {identifier} succeeded.") + logger.debug("Exiting wait_for_payment (succeeded)") + return True + if status == 'FAILED': + logger.error(f"Payment for {identifier} failed during wait.") + logger.debug("Exiting wait_for_payment (failed)") + return False + # Consider other final states like 'REFUNDED' if applicable + if status == 'REFUNDED': + logger.info(f"Swap for {identifier} was refunded during wait.") + logger.debug("Exiting wait_for_payment (refunded)") + return False + + time.sleep(1) + logger.warning(f"Wait for payment for {identifier} timed out.") + logger.debug("Exiting wait_for_payment (timeout)") + return False + + def disconnect(self): + """Disconnects from the Breez SDK.""" + logger.debug("Entering disconnect") + try: + # Check if the instance attribute exists and is not None + if hasattr(self, 'instance') and self.instance: + self.instance.disconnect() + logger.info("Breez SDK disconnected.") + else: + logger.warning("Disconnect called but SDK instance was not initialized or already disconnected.") + except Exception as e: + logger.error(f"Error disconnecting from Breez SDK: {e}") + # Decide if you want to re-raise or just log depending on context + # raise # Re-raising might prevent clean shutdown + + logger.debug("Exiting disconnect") + + + # --- Wallet Operations --- + def get_info(self) -> Dict[str, Any]: + """ + Fetches general wallet and blockchain information. + + Returns: + Dictionary containing wallet_info and blockchain_info. + """ + logger.debug("Entering get_info") + try: + self.wait_for_sync() + info = self.instance.get_info() + # Convert info object to dictionary for easier handling + info_dict = { + 'wallet_info': info.wallet_info.__dict__ if info.wallet_info else None, + 'blockchain_info': info.blockchain_info.__dict__ if info.blockchain_info else None, + } + logger.debug(f"Fetched wallet info successfully.") + logger.debug("Exiting get_info") + return info_dict + except Exception as e: + logger.error(f"Error getting info: {e}") + logger.debug("Exiting get_info (error)") + raise + + def list_payments(self, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + """ + Lists payment history with optional filters. + + Args: + params: Dictionary with optional filters (from_timestamp, to_timestamp, + offset, limit, filters, details). 'filters' should be a list + of breez_sdk_liquid.PaymentType members. 'details' should be + a breez_sdk_liquid.ListPaymentDetails object. + Returns: + List of payment dictionaries. + Raises: + Exception: For any SDK errors. + """ + logger.debug(f"Entering list_payments with params: {params}") + try: + #self.wait_for_sync() + from_ts = int(params.get('from_timestamp')) if params and params.get('from_timestamp') is not None else None + to_ts = int(params.get('to_timestamp')) if params and params.get('to_timestamp') is not None else None + offset = int(params.get('offset')) if params and params.get('offset') is not None else None + limit = int(params.get('limit')) if params and params.get('limit') is not None else None + + # --- Handle optional filters and details --- + filters = params.get('filters') if params else None # Expects List[PaymentType] + details_param = params.get('details') if params else None # Expects ListPaymentDetails + + # Add validation for filters/details types if needed + if filters is not None and not isinstance(filters, list): + logger.warning(f"Invalid type for 'filters' parameter: {type(filters)}") + # Decide whether to raise error or proceed without filter + # raise ValueError("'filters' parameter must be a list of PaymentType") + filters = None # Ignore invalid input + + # Validation for details_param is trickier as it's a union type + # We'll trust the caller passes the correct SDK object or None + + req = ListPaymentsRequest( + from_timestamp=from_ts, + to_timestamp=to_ts, + offset=offset, + limit=limit, + filters=filters, + details=details_param, + ) + # --- End handle optional filters and details --- + + payments = self.instance.list_payments(req) + + # Convert payment objects to dictionaries for easier handling + payment_list = [] + for payment in payments: + # Use a helper function if this conversion becomes complex/repeated + payment_dict = { + 'id': getattr(payment, 'id', None), # Payments might have an ID? Check SDK docs + 'timestamp': payment.timestamp, + 'amount_sat': payment.amount_sat, + 'fees_sat': payment.fees_sat, + 'payment_type': str(payment.payment_type), # Convert Enum to string + 'status': str(payment.status), # Convert Enum to string + 'details': self.sdk_to_dict(payment.details) if payment.details else None, # Include details dict + 'destination': getattr(payment, 'destination', None), # Optional field + 'tx_id': getattr(payment, 'tx_id', None), # Optional field + 'payment_hash': getattr(payment.details, 'payment_hash', None), # Often useful, from details + 'swap_id': getattr(payment.details, 'swap_id', None), # Often useful, from details + } + payment_list.append(payment_dict) + + logger.debug(f"Listed {len(payment_list)} payments.") + logger.debug("Exiting list_payments") + return payment_list + + except Exception as e: + logger.error(f"Error listing payments: {e}") + logger.debug("Exiting list_payments (error)") + raise + + def get_payment(self, identifier: str, identifier_type: str = 'payment_hash') -> Optional[Dict[str, Any]]: + """ + Retrieves a specific payment by hash or swap ID. + + Args: + identifier: The payment hash or swap ID string. + identifier_type: 'payment_hash' or 'swap_id'. + Returns: + Payment dictionary or None if not found. + Raises: + ValueError: If invalid identifier_type is provided. + Exception: For any SDK errors. + """ + logger.debug(f"Entering get_payment with identifier: {identifier}, type: {identifier_type}") + try: + self.wait_for_sync() + req = None + if identifier_type == 'payment_hash': + req = GetPaymentRequest.PAYMENT_HASH(identifier) + elif identifier_type == 'swap_id': + req = GetPaymentRequest.SWAP_ID(identifier) + else: + logger.warning(f"Invalid identifier_type for get_payment: {identifier_type}") + raise ValueError("identifier_type must be 'payment_hash' or 'swap_id'") + + payment = self.instance.get_payment(req) + if payment: + # Use a helper function if payment-to-dict conversion is common + payment_dict = { + 'id': getattr(payment, 'id', None), + 'timestamp': payment.timestamp, + 'amount_sat': payment.amount_sat, + 'fees_sat': payment.fees_sat, + 'payment_type': str(payment.payment_type), + 'status': str(payment.status), + 'details': self.sdk_to_dict(payment.details) if payment.details else None, + 'destination': getattr(payment, 'destination', None), + 'tx_id': getattr(payment, 'tx_id', None), + 'payment_hash': getattr(payment.details, 'payment_hash', None), + 'swap_id': getattr(payment.details, 'swap_id', None), + } + logger.debug(f"Fetched payment: {identifier}") + logger.debug("Exiting get_payment (found)") + return payment_dict + else: + logger.debug(f"Payment not found: {identifier}") + logger.debug("Exiting get_payment (not found)") + return None + + except Exception as e: + logger.error(f"Error getting payment {identifier}: {e}") + logger.debug("Exiting get_payment (error)") + raise + + # --- Sending Payments --- + def send_payment(self, destination: str, amount_sat: Optional[int] = None, amount_asset: Optional[float] = None, asset_id: Optional[str] = None, drain: bool = False) -> Dict[str, Any]: + """ + Prepares and sends a payment to a destination (BOLT11, Liquid BIP21/address) + for Bitcoin or other Liquid assets. + + Args: + destination: The payment destination string. + amount_sat: Optional amount in satoshis for Bitcoin payments. + amount_asset: Optional amount for asset payments (as float). + asset_id: Required if amount_asset is provided. The asset ID string. + drain: If True, sends all funds (overrides amount arguments). + Returns: + Dictionary with initiated payment details. + Raises: + ValueError: If inconsistent or missing amount arguments. + Exception: For any SDK errors. + """ + logger.debug(f"Entering send_payment to {destination} (amount_sat={amount_sat}, amount_asset={amount_asset}, asset_id={asset_id}, drain={drain})") + try: + self.wait_for_sync() + amount_obj = None + + if drain: + amount_obj = PayAmount.DRAIN + logger.debug("Sending payment using DRAIN amount.") + elif amount_sat is not None: + if amount_asset is not None or asset_id is not None: + logger.warning("Conflicting amount arguments: amount_sat provided with asset arguments.") + raise ValueError("Provide either amount_sat, or (amount_asset and asset_id), or drain=True.") + amount_obj = PayAmount.BITCOIN(amount_sat) + logger.debug(f"Sending Bitcoin payment with amount: {amount_sat} sat.") + elif amount_asset is not None and asset_id is not None: + if amount_sat is not None or drain: + logger.warning("Conflicting amount arguments: asset arguments provided with amount_sat or drain.") + raise ValueError("Provide either amount_sat, or (amount_asset and asset_id), or drain=True.") + # False is 'is_liquid_fee' - typically false for standard asset sends + amount_obj = PayAmount.ASSET(asset_id, amount_asset, False) + logger.debug(f"Sending asset payment {asset_id} with amount: {amount_asset}.") + else: + logger.warning("Missing or inconsistent amount arguments.") + raise ValueError("Provide either amount_sat, or (amount_asset and asset_id), or drain=True.") + + + prepare_req = PrepareSendRequest(destination=destination, amount=amount_obj) + prepare_res = self.instance.prepare_send_payment(prepare_req) + + # You might want to add a step here to check fees and potentially ask for confirmation + logger.info(f"Prepared send payment to {destination}. Fees: {prepare_res.fees_sat} sat.") + logger.debug(f"PrepareSendRequest response: {prepare_res.__dict__}") + + + req = SendPaymentRequest(prepare_response=prepare_res) + send_res = self.instance.send_payment(req) + + # You can track the payment status via the listener or check_payment_status later + initiated_payment_details = { + 'status': str(send_res.payment.status), # Initial status (likely PENDING) + 'destination': getattr(send_res.payment, 'destination', None), # May or may not be present + 'fees_sat': prepare_res.fees_sat, # Prepared fees, final fees might differ slightly + 'payment_hash': getattr(send_res.payment.details, 'payment_hash', None), # Likely present for lightning + 'swap_id': getattr(send_res.payment.details, 'swap_id', None), # Likely present for onchain/liquid swaps + } + logger.info(f"Send payment initiated to {destination}.") + logger.debug(f"Send payment initiated details: {initiated_payment_details}") + logger.debug("Exiting send_payment (initiated)") + + return initiated_payment_details + + except Exception as e: + logger.error(f"Error sending payment to {destination}: {e}") + logger.debug("Exiting send_payment (error)") + raise + + # --- Receiving Payments --- + def receive_payment(self, amount: int, payment_method: str = 'LIGHTNING', description: Optional[str] = None, asset_id: Optional[str] = None) -> Dict[str, Any]: + """ + Prepares and generates a receive address/invoice. + + Args: + amount: The amount to receive. + payment_method: 'LIGHTNING', 'BITCOIN_ADDRESS', or 'LIQUID_ADDRESS'. + description: Optional description for the payment (mainly for Lightning). + asset_id: Optional asset ID string for receiving specific assets on Liquid. + Returns: + Dictionary with destination (address/invoice) and prepared fees. + Raises: + ValueError: If invalid payment_method is provided. + Exception: For any SDK errors. + """ + logger.debug(f"Entering receive_payment (amount={amount}, method={payment_method}, asset={asset_id})") + try: + method = getattr(PaymentMethod, payment_method.upper(), None) + if not method: + logger.warning(f"Invalid payment_method: {payment_method}") + raise ValueError(f"Invalid payment_method: {payment_method}. Must be 'LIGHTNING', 'BITCOIN_ADDRESS', or 'LIQUID_ADDRESS'.") + + if asset_id: + receive_amount_obj = ReceiveAmount.ASSET(asset_id, amount) + logger.debug(f"Receiving asset {asset_id} with amount {amount}") + else: + receive_amount_obj = ReceiveAmount.BITCOIN(amount) + logger.debug(f"Receiving Bitcoin with amount {amount} sat.") + + + prepare_req = PrepareReceiveRequest(payment_method=method, amount=receive_amount_obj) + prepare_res = self.instance.prepare_receive_payment(prepare_req) + + logger.info(f"Prepared receive payment ({payment_method}). Fees: {prepare_res.fees_sat} sat.") + logger.debug(f"PrepareReceiveRequest response: {prepare_res.__dict__}") + + + req = ReceivePaymentRequest(prepare_response=prepare_res, description=description) + receive_res = self.instance.receive_payment(req) + + logger.info(f"Receive payment destination generated: {receive_res.destination}") + logger.debug(f"Receive payment response: {receive_res.__dict__}") + logger.debug("Exiting receive_payment") + + + return { + 'destination': receive_res.destination, + 'fees_sat': prepare_res.fees_sat, # Prepared fees, final fees might differ + } + except Exception as e: + logger.error(f"Error receiving payment ({payment_method}) for amount {amount}: {e}") + logger.debug("Exiting receive_payment (error)") + raise + + # --- Buy Bitcoin --- + def fetch_buy_bitcoin_limits(self) -> Dict[str, Any]: + """ + Fetches limits for buying Bitcoin (uses onchain limits). + + Returns: + Dictionary containing receive and send limits. + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering fetch_buy_bitcoin_limits") + try: + self.wait_for_sync() + limits = self.instance.fetch_onchain_limits() # Onchain limits apply to Buy/Sell + limits_dict = { + 'receive': limits.receive.__dict__ if limits.receive else None, + 'send': limits.send.__dict__ if limits.send else None, + } + logger.debug(f"Fetched buy/sell limits successfully.") + logger.debug("Exiting fetch_buy_bitcoin_limits") + return limits_dict + except Exception as e: + logger.error(f"Error fetching buy bitcoin limits: {e}") + logger.debug("Exiting fetch_buy_bitcoin_limits (error)") + raise + + def prepare_buy_bitcoin(self, provider: str, amount_sat: int) -> Dict[str, Any]: + """ + Prepares a buy Bitcoin request. + + Args: + provider: The buy provider string (e.g., 'MOONPAY'). + amount_sat: The amount in satoshis to buy. + Returns: + Dictionary with preparation details, including fees. + Raises: + ValueError: If invalid provider is provided. + Exception: For any SDK errors. + """ + logger.debug(f"Entering prepare_buy_bitcoin (provider={provider}, amount={amount_sat})") + try: + self.wait_for_sync() + buy_provider = getattr(BuyBitcoinProvider, provider.upper(), None) + if not buy_provider: + logger.warning(f"Invalid buy bitcoin provider: {provider}") + raise ValueError(f"Invalid buy bitcoin provider: {provider}.") + + req = PrepareBuyBitcoinRequest(provider=buy_provider, amount_sat=amount_sat) + prepare_res = self.instance.prepare_buy_bitcoin(req) + prepare_res_dict = prepare_res.__dict__ + logger.info(f"Prepared buy bitcoin with {provider}. Fees: {prepare_res.fees_sat} sat.") + logger.debug(f"PrepareBuyBitcoinRequest response: {prepare_res_dict}") + logger.debug("Exiting prepare_buy_bitcoin") + + return prepare_res_dict + except Exception as e: + logger.error(f"Error preparing buy bitcoin for {amount_sat} with {provider}: {e}") + logger.debug("Exiting prepare_buy_bitcoin (error)") + raise + + # Refined signature to expect the SDK object + def buy_bitcoin(self, prepare_response: PrepareBuyBitcoinResponse) -> str: + """ + Executes a buy Bitcoin request using prepared data. + + Args: + prepare_response: The PrepareBuyBitcoinResponse object returned by prepare_buy_bitcoin. + Returns: + The URL string to complete the purchase. + Raises: + TypeError: If prepare_response is not the correct type. + Exception: For any SDK errors. + """ + logger.debug("Entering buy_bitcoin") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(prepare_response, PrepareBuyBitcoinResponse): + logger.error(f"buy_bitcoin expects PrepareBuyBitcoinResponse object, but received {type(prepare_response)}.") + raise TypeError("buy_bitcoin expects the SDK PrepareBuyBitcoinResponse object") + + req = BuyBitcoinRequest(prepare_response=prepare_response) # Pass the actual object + url = self.instance.buy_bitcoin(req) + logger.info(f"Buy bitcoin URL generated.") + logger.debug("Exiting buy_bitcoin") + return url + except Exception as e: + logger.error(f"Error executing buy bitcoin: {e}") + logger.debug("Exiting buy_bitcoin (error)") + raise + + # --- Fiat Currencies --- + def list_fiat_currencies(self) -> List[Dict[str, Any]]: + """ + Lists supported fiat currencies. + + Returns: + List of fiat currency dictionaries. + """ + logger.debug("Entering list_fiat_currencies") + try: + self.wait_for_sync() # Fiat data might need sync + currencies = self.instance.list_fiat_currencies() + currencies_list = [c.__dict__ for c in currencies] + logger.debug(f"Listed {len(currencies_list)} fiat currencies.") + logger.debug("Exiting list_fiat_currencies") + return currencies_list + except Exception as e: + logger.error(f"Error listing fiat currencies: {e}") + logger.debug("Exiting list_fiat_currencies (error)") + raise + + def fetch_fiat_rates(self) -> List[Dict[str, Any]]: + """ + Fetches current fiat exchange rates. + + Returns: + List of fiat rate dictionaries. + """ + logger.debug("Entering fetch_fiat_rates") + try: + self.wait_for_sync() # Fiat data might need sync + rates = self.instance.fetch_fiat_rates() + rates_list = [r.__dict__ for r in rates] + logger.debug(f"Fetched {len(rates_list)} fiat rates.") + logger.debug("Exiting fetch_fiat_rates") + return rates_list + except Exception as e: + logger.error(f"Error fetching fiat rates: {e}") + logger.debug("Exiting fetch_fiat_rates (error)") + raise + + # --- LNURL Operations --- + def parse_input(self, input_str: str) -> Dict[str, Any]: + """ + Parses various input types (LNURL, addresses, invoices, etc.). + + Args: + input_str: The string input to parse. + Returns: + Dictionary representing the parsed input details. + Raises: + Exception: For any SDK errors during parsing. + """ + logger.debug(f"Entering parse_input with input: {input_str}") + try: + self.wait_for_sync() # Parsing might require network interaction + parsed_input = self.instance.parse(input_str) + # Convert the specific InputType object to a dictionary + # Access .data on the *instance* of the parsed input, not the type + if isinstance(parsed_input, InputType.BITCOIN_ADDRESS): + result = {'type': 'BITCOIN_ADDRESS', 'address': parsed_input.address.address} + elif isinstance(parsed_input, InputType.BOLT11): + result = {'type': 'BOLT11', 'invoice': parsed_input.invoice.__dict__} + elif isinstance(parsed_input, InputType.LN_URL_PAY): + # Access data on the instance: parsed_input.data + result = {'type': 'LN_URL_PAY', 'data': parsed_input.data.__dict__} + elif isinstance(parsed_input, InputType.LN_URL_AUTH): + # Access data on the instance: parsed_input.data + result = {'type': 'LN_URL_AUTH', 'data': parsed_input.data.__dict__} + elif isinstance(parsed_input, InputType.LN_URL_WITHDRAW): + # Access data on the instance: parsed_input.data + result = {'type': 'LN_URL_WITHDRAW', 'data': parsed_input.data.__dict__} + elif isinstance(parsed_input, InputType.LIQUID_ADDRESS): + result = {'type': 'LIQUID_ADDRESS', 'address': parsed_input.address.address} + elif isinstance(parsed_input, InputType.BIP21): + result = {'type': 'BIP21', 'data': parsed_input.bip21.__dict__} + elif isinstance(parsed_input, InputType.NODE_ID): + result = {'type': 'NODE_ID', 'node_id': parsed_input.node_id} + else: + # Log raw data for unhandled types to aid debugging + logger.warning(f"Parsed unknown input type: {type(parsed_input)}") + result = {'type': 'UNKNOWN', 'raw_input': input_str, 'raw_parsed_object': str(parsed_input)} + + logger.debug(f"Parsed input successfully. Type: {result.get('type')}") + logger.debug("Exiting parse_input") + + return result + except Exception as e: + logger.error(f"Error parsing input '{input_str}': {e}") + logger.debug("Exiting parse_input (error)") + raise + + # Corrected type hint to LnUrlPayRequestData + def prepare_lnurl_pay(self, data: LnUrlPayRequestData, amount_sat: int, comment: Optional[str] = None, validate_success_action_url: bool = True) -> Dict[str, Any]: + """ + Prepares an LNURL-Pay request. + + Args: + data: The LnUrlPayRequestData object from a parsed LNURL_PAY input's .data attribute. + amount_sat: Amount in satoshis. + comment: Optional comment. + validate_success_action_url: Whether to validate the success action URL. + Returns: + Dictionary with preparation details. + Raises: + TypeError: If data is not the correct object type. + Exception: For any SDK errors. + """ + logger.debug(f"Entering prepare_lnurl_pay (amount={amount_sat}, comment={comment})") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(data, LnUrlPayRequestData): + logger.error(f"prepare_lnurl_pay expects LnUrlPayRequestData object, but received {type(data)}.") + raise TypeError("prepare_lnurl_pay expects the SDK LnUrlPayRequestData object") + + + # Handle amount format for PayAmount + pay_amount = PayAmount.BITCOIN(amount_sat) + + req = PrepareLnUrlPayRequest( + data=data, # Use the passed object + amount=pay_amount, + comment=comment, + validate_success_action_url=validate_success_action_url, + bip353_address=getattr(data, 'bip353_address', None) # Get bip353_address from the object + ) + prepare_res = self.instance.prepare_lnurl_pay(req) + prepare_res_dict = prepare_res.__dict__ + logger.info(f"Prepared LNURL-Pay. Fees: {prepare_res.fees_sat} sat.") + logger.debug(f"PrepareLnUrlPayRequest response: {prepare_res_dict}") + logger.debug("Exiting prepare_lnurl_pay") + + return prepare_res_dict + except Exception as e: + logger.error(f"Error preparing LNURL-Pay: {e}") + logger.debug("Exiting prepare_lnurl_pay (error)") + raise + + # Refined signature to expect the SDK object + def lnurl_pay(self, prepare_response: PrepareLnUrlPayResponse) -> Optional[Dict[str, Any]]: + """ + Executes an LNURL-Pay payment using prepared data. + + Args: + prepare_response: The PrepareLnUrlPayResponse object returned by prepare_lnurl_pay. + Returns: + Dictionary with payment result details, or None if no specific result. + Raises: + TypeError: If prepare_response is not the correct type. + Exception: For any SDK errors. + """ + logger.debug("Entering lnurl_pay") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(prepare_response, PrepareLnUrlPayResponse): + logger.error(f"lnurl_pay expects PrepareLnUrlPayResponse object, but received {type(prepare_response)}.") + raise TypeError("lnurl_pay expects the SDK PrepareLnUrlPayResponse object") + + req = LnUrlPayRequest(prepare_response=prepare_response) # Pass the actual object + result = self.instance.lnurl_pay(req) + result_dict = result.__dict__ if result else None # Result type depends on success action + logger.info("Executed LNURL-Pay.") + logger.debug(f"LNURL-Pay result: {result_dict}") + logger.debug("Exiting lnurl_pay") + return result_dict + except Exception as e: + logger.error(f"Error executing LNURL-Pay: {e}") + logger.debug("Exiting lnurl_pay (error)") + raise + + # Corrected type hint to LnUrlAuthRequestData + def lnurl_auth(self, data: LnUrlAuthRequestData) -> bool: + """ + Performs LNURL-Auth. + + Args: + data: The LnUrlAuthRequestData object from a parsed LNURL_AUTH input's .data attribute. + Returns: + True if authentication was successful, False otherwise. + Raises: + TypeError: If data is not the correct object type. + Exception: For any SDK errors. + """ + logger.debug("Entering lnurl_auth") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(data, LnUrlAuthRequestData): + logger.error(f"lnurl_auth expects LnUrlAuthRequestData object, but received {type(data)}.") + raise TypeError("lnurl_auth expects the SDK LnUrlAuthRequestData object") + + result = self.instance.lnurl_auth(data) # Pass the actual object + is_ok = result.is_ok() + if is_ok: + logger.info("LNURL-Auth successful.") + else: + # Log the error message from the result if available + error_msg = getattr(result, 'error', 'Unknown error') + logger.warning(f"LNURL-Auth failed. Error: {error_msg}") + logger.debug(f"LNURL-Auth result: {is_ok}") + logger.debug("Exiting lnurl_auth") + return is_ok + except Exception as e: + logger.error(f"Error performing LNURL-Auth: {e}") + logger.debug("Exiting lnurl_auth (error)") + raise + + # Corrected type hint to LnurlWithdrawRequestData + def lnurl_withdraw(self, data: LnUrlWithdrawRequestData, amount_msat: int, comment: Optional[str] = None) -> Optional[Dict[str, Any]]: + """ + Performs LNURL-Withdraw. + + Args: + data: The LnUrlWithdrawRequestData object from a parsed LNURL_WITHDRAW input's .data attribute. + amount_msat: Amount in millisatoshis to withdraw. + comment: Optional comment string. + Returns: + Dictionary with withdrawal result details, or None if no specific result. + Raises: + TypeError: If data is not the correct object type. + Exception: For any SDK errors. + """ + logger.debug(f"Entering lnurl_withdraw (amount_msat={amount_msat}, comment={comment})") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(data, LnUrlWithdrawRequestData): + logger.error(f"lnurl_withdraw expects LnUrlWithdrawRequestData object, but received {type(data)}.") + raise TypeError("lnurl_withdraw expects the SDK LnUrlWithdrawRequestData object") + + # Basic validation for amount and comment + if not isinstance(amount_msat, int) or amount_msat <= 0: + logger.warning(f"Invalid amount_msat provided: {amount_msat}") + raise ValueError("amount_msat must be a positive integer.") + if comment is not None and not isinstance(comment, str): + logger.warning(f"Invalid comment type provided: {type(comment)}") + raise ValueError("comment must be a string or None.") + + result = self.instance.lnurl_withdraw(data, amount_msat, comment) # Pass the actual object + result_dict = result.__dict__ if result else None # Check result type + logger.info("Executed LNURL-Withdraw.") + logger.debug(f"LNURL-Withdraw result: {result_dict}") + logger.debug("Exiting lnurl_withdraw") + return result_dict + except Exception as e: + logger.error(f"Error executing LNURL-Withdraw: {e}") + logger.debug("Exiting lnurl_withdraw (error)") + raise + + # --- Onchain Operations --- + # fetch_pay_onchain_limits is covered by fetch_onchain_limits (public method) + + def prepare_pay_onchain(self, amount_sat: Optional[int] = None, drain: bool = False, fee_rate_sat_per_vbyte: Optional[int] = None) -> Dict[str, Any]: + """ + Prepares an onchain payment (Bitcoin address). + + Args: + amount_sat: Optional amount in satoshis (required unless drain is True). + drain: If True, prepares to send all funds. + fee_rate_sat_per_vbyte: Optional custom fee rate. + Returns: + Dictionary with preparation details. + Raises: + ValueError: If amount is missing for non-drain payment. + Exception: For any SDK errors. + """ + logger.debug(f"Entering prepare_pay_onchain (amount={amount_sat}, drain={drain}, fee_rate={fee_rate_sat_per_vbyte})") + try: + # Determine amount object based on inputs + if drain: + amount_obj = PayAmount.DRAIN + logger.debug("Preparing onchain payment using DRAIN amount.") + elif amount_sat is not None: + amount_obj = PayAmount.BITCOIN(amount_sat) + logger.debug(f"Preparing onchain payment with amount: {amount_sat} sat.") + else: + logger.warning("Amount is missing for non-drain pay onchain.") + raise ValueError("Amount must be provided for non-drain payments.") + + # Optional fee rate validation + if fee_rate_sat_per_vbyte is not None and (not isinstance(fee_rate_sat_per_vbyte, int) or fee_rate_sat_per_vbyte <= 0): + logger.warning(f"Invalid fee_rate_sat_per_vbyte provided: {fee_rate_sat_per_vbyte}") + raise ValueError("fee_rate_sat_per_vbyte must be a positive integer or None.") + + + req = PreparePayOnchainRequest(amount=amount_obj, fee_rate_sat_per_vbyte=fee_rate_sat_per_vbyte) + prepare_res = self.instance.prepare_pay_onchain(req) + prepare_res_dict = prepare_res.__dict__ + logger.info(f"Prepared pay onchain. Total fees: {prepare_res.total_fees_sat} sat.") + logger.debug(f"PreparePayOnchainRequest response: {prepare_res_dict}") + logger.debug("Exiting prepare_pay_onchain") + return prepare_res_dict + except Exception as e: + logger.error(f"Error preparing pay onchain: {e}") + logger.debug("Exiting prepare_pay_onchain (error)") + raise + + # Refined signature to expect the SDK object + def pay_onchain(self, address: str, prepare_response: PreparePayOnchainResponse): + """ + Executes an onchain payment using prepared data. + + Args: + address: The destination Bitcoin address string. + prepare_response: The PreparePayOnchainResponse object returned by prepare_pay_onchain. + Raises: + TypeError: If prepare_response is not the correct type. + ValueError: If address is invalid. + Exception: For any SDK errors. + """ + logger.debug(f"Entering pay_onchain to {address}") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(prepare_response, PreparePayOnchainResponse): + logger.error(f"pay_onchain expects PreparePayOnchainResponse object, but received {type(prepare_response)}.") + raise TypeError("pay_onchain expects the SDK PreparePayOnchainResponse object") + + # Basic check for address format (could add more robust validation) + if not isinstance(address, str) or not address: + logger.warning("Invalid or empty destination address provided for pay_onchain.") + raise ValueError("Destination address must be a non-empty string.") + + + req = PayOnchainRequest(address=address, prepare_response=prepare_response) # Pass the actual object + self.instance.pay_onchain(req) + logger.info(f"Onchain payment initiated to {address}.") + logger.debug("Exiting pay_onchain") + + # Note: Onchain payments might not trigger an immediate SDK event like lightning payments + # You might need to poll list_payments or rely on webhooks to track final status. + + except Exception as e: + logger.error(f"Error executing pay onchain to {address}: {e}") + logger.debug("Exiting pay_onchain (error)") + raise + + # list_refundable_payments method (already present, returns list of RefundableSwap objects) + def list_refundable_payments(self) -> List[RefundableSwap]: + """ + Lists refundable onchain swaps. + + Returns: + List of RefundableSwap objects. + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering list_refundable_payments") + try: + self.wait_for_sync() # Ensure sync before executing + refundable_payments = self.instance.list_refundables() + logger.debug(f"Found {len(refundable_payments)} refundable payments.") + logger.debug("Exiting list_refundable_payments") + return refundable_payments # Return the list of objects directly + + except Exception as e: + logger.error(f"Error listing refundable payments: {e}") + logger.debug("Exiting list_refundable_payments (error)") + raise + + # Updated signature and type hint to RefundableSwap and explicit refund_address + def execute_refund(self, refundable_swap: RefundableSwap, refund_address: str, fee_rate_sat_per_vbyte: int): + """ + Executes a refund for a refundable swap. + + Args: + refundable_swap: The RefundableSwap object to refund. + refund_address: The destination address string for the refund. + fee_rate_sat_per_vbyte: The desired fee rate in satoshis per vbyte for the refund transaction. + Raises: + TypeError: If refundable_swap is not the correct type. + ValueError: If refund_address or fee_rate_sat_per_vbyte is invalid. + Exception: For any SDK errors. + """ + # Using getattr with a default for logging in case refundable_swap is None or malformed (though type hint should prevent this) + logger.debug(f"Entering execute_refund for swap {getattr(refundable_swap, 'swap_address', 'N/A')} to {refund_address} with fee rate {fee_rate_sat_per_vbyte}") + try: + self.wait_for_sync() + # Check if it's the correct type of SDK object + if not isinstance(refundable_swap, RefundableSwap): + logger.error(f"execute_refund expects RefundableSwap object, but received {type(refundable_swap)}.") + raise TypeError("execute_refund expects the SDK RefundableSwap object") + + # Basic check for refund_address format (could add more robust validation) + if not isinstance(refund_address, str) or not refund_address: + logger.warning("Invalid or empty refund_address provided for execute_refund.") + raise ValueError("Refund destination address must be a non-empty string.") + + if not isinstance(fee_rate_sat_per_vbyte, int) or fee_rate_sat_per_vbyte <= 0: + logger.warning(f"Invalid fee_rate_sat_per_vbyte provided: {fee_rate_sat_per_vbyte}") + raise ValueError("fee_rate_sat_per_vbyte must be a positive integer.") + + + req = RefundRequest( + swap_address=refundable_swap.swap_address, # Use address from the object + refund_address=refund_address, + fee_rate_sat_per_vbyte=fee_rate_sat_per_vbyte + ) + self.instance.refund(req) + logger.info(f"Refund initiated for swap {refundable_swap.swap_address} to {refund_address}.") + logger.debug("Exiting execute_refund") + + # Note: Onchain refunds might not trigger an immediate SDK event + # You might need to poll list_payments or rely on webhooks to track final status. + + + except Exception as e: + logger.error(f"Error executing refund for swap {getattr(refundable_swap, 'swap_address', 'N/A')}: {e}") + logger.debug("Exiting execute_refund (error)") + raise + + # rescan_swaps method (already present) + def rescan_swaps(self): + """ + Rescans onchain swaps. + + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering rescan_swaps") + try: + self.wait_for_sync() # Ensure sync before executing + self.instance.rescan_onchain_swaps() + logger.info("Onchain swaps rescan initiated.") + logger.debug("Exiting rescan_swaps") + + except Exception as e: + logger.error(f"Error rescanning swaps: {e}") + logger.debug("Exiting rescan_swaps (error)") + raise + + def recommended_fees(self) -> Dict[str, int]: + """ + Fetches recommended transaction fees. + + Returns: + Dictionary with fee rate estimates (e.g., {'fastest': 100, 'half_hour': 50, ...}). + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering recommended_fees") + try: + self.wait_for_sync() # Fee estimates might need network + fees = self.instance.recommended_fees() + # Assuming recommended_fees returns an object with __dict__ or similar fee structure + fees_dict = fees.__dict__ if fees else {} # Convert to dict + logger.debug(f"Fetched recommended fees: {fees_dict}") + logger.debug("Exiting recommended_fees") + return fees_dict + except Exception as e: + logger.error(f"Error fetching recommended fees: {e}") + logger.debug("Exiting recommended_fees (error)") + raise + + def handle_payments_waiting_fee_acceptance(self): + """ + Fetches and automatically accepts payments waiting for fee acceptance. + In a real app, you would add logic to decide whether to accept the fees. + + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering handle_payments_waiting_fee_acceptance") + try: + self.wait_for_sync() + logger.info("Checking for payments waiting for fee acceptance...") + # Filter for WAITING_FEE_ACCEPTANCE state + payments_waiting = self.instance.list_payments( + ListPaymentsRequest(states=[PaymentState.WAITING_FEE_ACCEPTANCE]) + ) + + handled_count = 0 + for payment in payments_waiting: + # Double-check payment type and swap_id as per doc example + if not isinstance(payment.details, PaymentDetails.BITCOIN) or not payment.details.swap_id: + logger.warning(f"Skipping payment in WAITING_FEE_ACCEPTANCE state without Bitcoin details or swap_id: {getattr(payment, 'destination', 'N/A')}") + continue + + swap_id = payment.details.swap_id + logger.info(f"Found payment waiting fee acceptance: {getattr(payment, 'destination', 'N/A')} (Swap ID: {swap_id})") + + fetch_fees_req = FetchPaymentProposedFeesRequest(swap_id=swap_id) + fetch_fees_response = self.instance.fetch_payment_proposed_fees(fetch_fees_req) + + logger.info( + f"Payer sent {fetch_fees_response.payer_amount_sat} " + f"and currently proposed fees are {fetch_fees_response.fees_sat}" + ) + + # --- Decision Point: Accept Fees? --- + # In a real application, you would implement logic here to decide if the proposed fees + # are acceptable based on your application's criteria. + # For this example, we will automatically accept. + logger.info(f"Automatically accepting proposed fees for swap {swap_id}.") + # --- End Decision Point --- + + accept_fees_req = AcceptPaymentProposedFeesRequest(response=fetch_fees_response) + self.instance.accept_payment_proposed_fees(accept_fees_req) + logger.info(f"Accepted proposed fees for swap {swap_id}.") + handled_count += 1 + + logger.info(f"Finished checking for payments waiting fee acceptance. Handled {handled_count}.") + logger.debug("Exiting handle_payments_waiting_fee_acceptance") + + except Exception as e: + logger.error(f"Error handling payments waiting fee acceptance: {e}") + logger.debug("Exiting handle_payments_waiting_fee_acceptance (error)") + raise + + + # --- Working with Non-Bitcoin Assets --- + # Asset Metadata configuration is done in __init__ + + # prepare_receive_asset is covered by receive_payment with asset_id parameter + + # prepare_send_payment_asset is covered by the updated send_payment with asset_id parameter + + def fetch_asset_balance(self) -> Dict[str, Any]: + """ + Fetches the balance of all assets (Bitcoin and others). + Note: This information is part of get_info(). + + Returns: + Dictionary containing asset balances. + Raises: + Exception: For any SDK errors from get_info. + """ + logger.debug("Entering fetch_asset_balance") + try: + # This information is part of get_info().wallet_info.asset_balances + # Calling get_info handles sync and error logging + info = self.get_info() + # Extract asset_balances from the returned info dictionary + asset_balances = info.get('wallet_info', {}).get('asset_balances', {}) + + # The asset_balances value is a list of AssetBalance objects. + # You might want to convert these to dictionaries too for consistency if needed. + # For now, returning the list of objects as is fetched by get_info. + # If conversion is needed: + # converted_balances = [bal.__dict__ for bal in asset_balances] + + logger.debug(f"Fetched asset balances: {asset_balances}") + logger.debug("Exiting fetch_asset_balance") + return asset_balances # Or return converted_balances + + except Exception as e: + # get_info already logs, this catch is mainly to ensure debug exit logging + # If get_info fails, it raises, so this block might not be reached + logger.error(f"Error fetching asset balance (via get_info): {e}") + logger.debug("Exiting fetch_asset_balance (error)") + raise + + + # --- Webhook Management --- + def register_webhook(self, webhook_url: str): + """ + Registers a webhook URL for receiving notifications. + + Args: + webhook_url: The URL string to register. + Raises: + ValueError: If webhook_url is invalid. + Exception: For any SDK errors. + """ + logger.debug(f"Entering register_webhook with URL: {webhook_url}") + try: + self.wait_for_sync() # Might require network + # Basic URL format validation (can be made more robust) + if not isinstance(webhook_url, str) or not webhook_url.startswith('https://'): + logger.warning(f"Invalid webhook_url provided: {webhook_url}") + raise ValueError("Webhook URL must be a valid HTTPS URL.") + + self.instance.register_webhook(webhook_url) + logger.info(f"Webhook registered: {webhook_url}") + logger.debug("Exiting register_webhook") + except Exception as e: + logger.error(f"Error registering webhook {webhook_url}: {e}") + logger.debug("Exiting register_webhook (error)") + raise + + def unregister_webhook(self): + """ + Unregisters the currently registered webhook. + + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering unregister_webhook") + try: + self.wait_for_sync() # Might require network + self.instance.unregister_webhook() + logger.info("Webhook unregistered.") + logger.debug("Exiting unregister_webhook") + except Exception as e: + logger.error(f"Error unregistering webhook: {e}") + logger.debug("Exiting unregister_webhook (error)") + raise + + # --- Utilities and Message Signing --- + # parse_input is implemented above + + def sign_message(self, message: str) -> Dict[str, str]: + """ + Signs a message with the wallet's key. + + Args: + message: The message string to sign. + Returns: + Dictionary with the signature and the wallet's public key string. + Raises: + ValueError: If message is invalid. + Exception: For any SDK errors. + """ + # Log truncated message to avoid logging potentially sensitive full messages + logger.debug(f"Entering sign_message with message (truncated): {message[:50]}...") + try: + self.wait_for_sync() + if not isinstance(message, str) or not message: + logger.warning("Invalid or empty message provided for signing.") + raise ValueError("Message to sign must be a non-empty string.") + + req = SignMessageRequest(message=message) + sign_res = self.instance.sign_message(req) + # Fetch info AFTER signing to get the pubkey that was used + info = self.instance.get_info() + + pubkey = info.wallet_info.pubkey if info and info.wallet_info else None + + if not pubkey: + logger.warning("Could not retrieve wallet pubkey after signing message.") + # Decide how to handle this - return None for pubkey or raise error + # Returning None for pubkey might be acceptable, the signature is the main result. + pass + + + result = { + 'signature': sign_res.signature, + 'pubkey': pubkey, + } + logger.info("Message signed.") + logger.debug("Exiting sign_message") + return result + except Exception as e: + logger.error(f"Error signing message: {e}") + logger.debug("Exiting sign_message (error)") + raise + + def check_message(self, message: str, pubkey: str, signature: str) -> bool: + """ + Verifies a signature against a message and public key. + + Args: + message: The original message string. + pubkey: The public key string used for signing. + signature: The signature string to verify. + Returns: + True if the signature is valid, False otherwise. + Raises: + ValueError: If message, pubkey, or signature are invalid. + Exception: For any SDK errors. + """ + logger.debug(f"Entering check_message for message (truncated): {message[:50]}...") + try: + self.wait_for_sync() # Might require network to verify + if not isinstance(message, str) or not message: + logger.warning("Invalid or empty message provided for checking.") + raise ValueError("Message to check must be a non-empty string.") + if not isinstance(pubkey, str) or not pubkey: + logger.warning("Invalid or empty pubkey provided for checking.") + raise ValueError("Pubkey must be a non-empty string.") + if not isinstance(signature, str) or not signature: + logger.warning("Invalid or empty signature provided for checking.") + raise ValueError("Signature must be a non-empty string.") + + + req = CheckMessageRequest(message=message, pubkey=pubkey, signature=signature) + check_res = self.instance.check_message(req) + is_valid = check_res.is_valid + logger.info(f"Message signature check result: {is_valid}") + logger.debug("Exiting check_message") + return is_valid + except Exception as e: + logger.error(f"Error checking message signature: {e}") + logger.debug("Exiting check_message (error)") + raise + + # External Input Parser configuration is done in __init__ + + # Payment Limits + # Keeping the explicit fetch methods as they are clearer + + def fetch_lightning_limits(self) -> Dict[str, Any]: + """ + Fetches current Lightning payment limits. + + Returns: + Dictionary containing receive and send limits. + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering fetch_lightning_limits") + try: + self.wait_for_sync() + limits = self.instance.fetch_lightning_limits() + limits_dict = { + 'receive': limits.receive.__dict__ if limits.receive else None, + 'send': limits.send.__dict__ if limits.send else None, + } + logger.debug(f"Fetched lightning limits: {limits_dict}") + logger.debug("Exiting fetch_lightning_limits") + return limits_dict + except Exception as e: + logger.error(f"Error fetching lightning limits: {e}") + logger.debug("Exiting fetch_lightning_limits (error)") + raise + + def fetch_onchain_limits(self) -> Dict[str, Any]: + """ + Fetches current onchain payment limits (used for Bitcoin send/receive). + + Returns: + Dictionary containing receive and send limits. + Raises: + Exception: For any SDK errors. + """ + logger.debug("Entering fetch_onchain_limits") + try: + self.wait_for_sync() + limits = self.instance.fetch_onchain_limits() + limits_dict = { + 'receive': limits.receive.__dict__ if limits.receive else None, + 'send': limits.send.__dict__ if limits.send else None, + } + logger.debug(f"Fetched onchain limits: {limits_dict}") + logger.debug("Exiting fetch_onchain_limits") + return limits_dict + except Exception as e: + logger.error(f"Error fetching onchain limits: {e}") + logger.debug("Exiting fetch_onchain_limits (error)") + raise + + def sdk_to_dict(self, obj): + if isinstance(obj, (str, int, float, bool, type(None))): + return obj + if isinstance(obj, list): + return [self.sdk_to_dict(i) for i in obj] + if hasattr(obj, '__dict__'): + return {k: self.sdk_to_dict(v) for k, v in obj.__dict__.items()} + return str(obj) # fallback + + def check_payment_status(self, destination: str) -> Dict[str, Any]: + """ + Checks the status of a specific payment by its destination/invoice. + + Args: + destination: The payment destination (invoice) string to check. + Returns: + Dictionary containing payment status information: + { + 'status': str, # The payment status (e.g., 'PENDING', 'SUCCEEDED', 'FAILED') + 'amount_sat': int, # Amount in satoshis + 'fees_sat': int, # Fees paid in satoshis + 'payment_time': int, # Unix timestamp of the payment + 'payment_hash': str, # Payment hash if available + 'error': str, # Error message if any + } + Raises: + ValueError: If destination is invalid. + Exception: For any SDK errors. + """ + logger.debug(f"Entering check_payment_status for destination: {destination[:30]}...") + try: + if not isinstance(destination, str) or not destination: + logger.warning("Invalid or empty destination provided.") + raise ValueError("Destination must be a non-empty string.") + + # First check the payment status in our listener's cache + logger.debug("Checking payment status in listener cache...") + cached_status = self.listener.get_payment_status(destination) + if cached_status: + logger.debug(f"Found cached payment status: {cached_status}") + # If we have a cached final status, we can return it immediately + if cached_status in ['SUCCEEDED', 'FAILED']: + logger.info(f"Returning cached final status: {cached_status}") + return { + 'status': cached_status, + 'amount_sat': None, # These would be None for cached statuses + 'fees_sat': None, + 'payment_time': None, + 'payment_hash': None, + 'error': None + } + + # If no cached final status, check the actual payments + logger.debug("No cached final status found, querying payment list...") + try: + payments = self.instance.list_payments(ListPaymentsRequest()) + logger.debug(f"Found {len(payments)} total payments") + except Exception as e: + logger.error(f"Error querying payment list: {e}") + raise + + # Find the most recent payment matching the destination + matching_payment = None + for payment in payments: + logger.debug(f"Checking payment: {getattr(payment, 'destination', 'No destination')} == {destination}") + if hasattr(payment, 'destination') and payment.destination == destination: + if matching_payment is None or payment.timestamp > matching_payment.timestamp: + matching_payment = payment + logger.debug("Found matching payment or found more recent matching payment") + + if matching_payment: + # Extract payment details + status = str(matching_payment.status) + details = matching_payment.details + + result = { + 'status': status, + 'amount_sat': matching_payment.amount_sat, + 'fees_sat': matching_payment.fees_sat, + 'payment_time': matching_payment.timestamp, + 'payment_hash': getattr(details, 'payment_hash', None), + 'error': getattr(matching_payment, 'error', None) + } + + logger.info(f"Found payment status for {destination[:30]}...: {status}") + logger.debug(f"Payment details: {result}") + logger.debug("Exiting check_payment_status") + return result + else: + logger.info(f"No payment found for destination: {destination[:30]}...") + return { + 'status': 'NOT_FOUND', + 'amount_sat': None, + 'fees_sat': None, + 'payment_time': None, + 'payment_hash': None, + 'error': 'Payment not found' + } + + except Exception as e: + logger.error(f"Error checking payment status for {destination[:30]}...: {str(e)}") + logger.exception("Full error details:") + raise \ No newline at end of file diff --git a/fly/requirements.txt b/fly/requirements.txt new file mode 100644 index 0000000..2982587 --- /dev/null +++ b/fly/requirements.txt @@ -0,0 +1,4 @@ +fastapi +breez-sdk-liquid +python-dotenv + From 4201f7ce79825ef560daf835cfcada6b70d47585 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Fri, 16 May 2025 16:03:28 +0200 Subject: [PATCH 4/5] remove wait_for_sync from all calls --- fly/main.py | 61 ++++++++++++++++++++++++++++++++++++++++++++ fly/nodeless.py | 68 ++++++++++++++++++++++++++++++------------------- 2 files changed, 103 insertions(+), 26 deletions(-) diff --git a/fly/main.py b/fly/main.py index abd7b5d..1ad2299 100644 --- a/fly/main.py +++ b/fly/main.py @@ -114,6 +114,12 @@ class LnurlWithdrawBody(BaseModel): amount_msat: int comment: Optional[str] = None +# Exchange Rate Models +class ExchangeRateResponse(BaseModel): + currency: Optional[str] = None + rate: Optional[float] = None + rates: Optional[Dict[str, float]] = None + # --- Dependencies --- async def get_api_key(api_key: str = Header(None, alias=API_KEY_NAME)): if not API_KEY: @@ -334,6 +340,61 @@ async def withdraw( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) +@app.get("/exchange_rates/{currency}", response_model=ExchangeRateResponse) +async def get_exchange_rate( + currency: Optional[str] = None, + api_key: str = Depends(get_api_key), + handler: PaymentHandler = Depends(get_payment_handler) +): + """ + Get current exchange rates, optionally filtered by currency. + + Args: + currency: Optional currency code (e.g., 'EUR', 'USD'). If not provided, returns all rates. + Returns: + Exchange rate information for the specified currency or all available currencies. + """ + logger.info(f"Received exchange rate request for currency: {currency}") + try: + result = handler.get_exchange_rate(currency) + + # Format response based on whether a specific currency was requested + if currency: + return ExchangeRateResponse( + currency=result['currency'], + rate=result['rate'] + ) + else: + return ExchangeRateResponse(rates=result) + + except ValueError as e: + logger.error(f"Currency not found: {str(e)}") + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Error fetching exchange rate: {str(e)}") + logger.exception("Full error details:") + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/exchange_rates", response_model=ExchangeRateResponse) +async def get_all_exchange_rates( + api_key: str = Depends(get_api_key), + handler: PaymentHandler = Depends(get_payment_handler) +): + """ + Get all available exchange rates. + + Returns: + Dictionary of all available exchange rates. + """ + logger.info("Received request for all exchange rates") + try: + result = handler.get_exchange_rate() + return ExchangeRateResponse(rates=result) + except Exception as e: + logger.error(f"Error fetching exchange rates: {str(e)}") + logger.exception("Full error details:") + raise HTTPException(status_code=500, detail=str(e)) + app.include_router(ln_router) if __name__ == "__main__": diff --git a/fly/nodeless.py b/fly/nodeless.py index ad7bc92..fbb6445 100644 --- a/fly/nodeless.py +++ b/fly/nodeless.py @@ -274,7 +274,6 @@ class PaymentHandler: """ logger.debug("Entering get_info") try: - self.wait_for_sync() info = self.instance.get_info() # Convert info object to dictionary for easier handling info_dict = { @@ -305,7 +304,6 @@ class PaymentHandler: """ logger.debug(f"Entering list_payments with params: {params}") try: - #self.wait_for_sync() from_ts = int(params.get('from_timestamp')) if params and params.get('from_timestamp') is not None else None to_ts = int(params.get('to_timestamp')) if params and params.get('to_timestamp') is not None else None offset = int(params.get('offset')) if params and params.get('offset') is not None else None @@ -380,7 +378,6 @@ class PaymentHandler: """ logger.debug(f"Entering get_payment with identifier: {identifier}, type: {identifier_type}") try: - self.wait_for_sync() req = None if identifier_type == 'payment_hash': req = GetPaymentRequest.PAYMENT_HASH(identifier) @@ -439,7 +436,6 @@ class PaymentHandler: """ logger.debug(f"Entering send_payment to {destination} (amount_sat={amount_sat}, amount_asset={amount_asset}, asset_id={asset_id}, drain={drain})") try: - self.wait_for_sync() amount_obj = None if drain: @@ -560,7 +556,6 @@ class PaymentHandler: """ logger.debug("Entering fetch_buy_bitcoin_limits") try: - self.wait_for_sync() limits = self.instance.fetch_onchain_limits() # Onchain limits apply to Buy/Sell limits_dict = { 'receive': limits.receive.__dict__ if limits.receive else None, @@ -589,7 +584,6 @@ class PaymentHandler: """ logger.debug(f"Entering prepare_buy_bitcoin (provider={provider}, amount={amount_sat})") try: - self.wait_for_sync() buy_provider = getattr(BuyBitcoinProvider, provider.upper(), None) if not buy_provider: logger.warning(f"Invalid buy bitcoin provider: {provider}") @@ -623,7 +617,6 @@ class PaymentHandler: """ logger.debug("Entering buy_bitcoin") try: - self.wait_for_sync() # Check if it's the correct type of SDK object if not isinstance(prepare_response, PrepareBuyBitcoinResponse): logger.error(f"buy_bitcoin expects PrepareBuyBitcoinResponse object, but received {type(prepare_response)}.") @@ -649,7 +642,6 @@ class PaymentHandler: """ logger.debug("Entering list_fiat_currencies") try: - self.wait_for_sync() # Fiat data might need sync currencies = self.instance.list_fiat_currencies() currencies_list = [c.__dict__ for c in currencies] logger.debug(f"Listed {len(currencies_list)} fiat currencies.") @@ -669,7 +661,6 @@ class PaymentHandler: """ logger.debug("Entering fetch_fiat_rates") try: - self.wait_for_sync() # Fiat data might need sync rates = self.instance.fetch_fiat_rates() rates_list = [r.__dict__ for r in rates] logger.debug(f"Fetched {len(rates_list)} fiat rates.") @@ -694,7 +685,6 @@ class PaymentHandler: """ logger.debug(f"Entering parse_input with input: {input_str}") try: - self.wait_for_sync() # Parsing might require network interaction parsed_input = self.instance.parse(input_str) # Convert the specific InputType object to a dictionary # Access .data on the *instance* of the parsed input, not the type @@ -749,7 +739,6 @@ class PaymentHandler: """ logger.debug(f"Entering prepare_lnurl_pay (amount={amount_sat}, comment={comment})") try: - self.wait_for_sync() # Check if it's the correct type of SDK object if not isinstance(data, LnUrlPayRequestData): logger.error(f"prepare_lnurl_pay expects LnUrlPayRequestData object, but received {type(data)}.") @@ -793,7 +782,6 @@ class PaymentHandler: """ logger.debug("Entering lnurl_pay") try: - self.wait_for_sync() # Check if it's the correct type of SDK object if not isinstance(prepare_response, PrepareLnUrlPayResponse): logger.error(f"lnurl_pay expects PrepareLnUrlPayResponse object, but received {type(prepare_response)}.") @@ -826,7 +814,6 @@ class PaymentHandler: """ logger.debug("Entering lnurl_auth") try: - self.wait_for_sync() # Check if it's the correct type of SDK object if not isinstance(data, LnUrlAuthRequestData): logger.error(f"lnurl_auth expects LnUrlAuthRequestData object, but received {type(data)}.") @@ -865,7 +852,6 @@ class PaymentHandler: """ logger.debug(f"Entering lnurl_withdraw (amount_msat={amount_msat}, comment={comment})") try: - self.wait_for_sync() # Check if it's the correct type of SDK object if not isinstance(data, LnUrlWithdrawRequestData): logger.error(f"lnurl_withdraw expects LnUrlWithdrawRequestData object, but received {type(data)}.") @@ -953,7 +939,6 @@ class PaymentHandler: """ logger.debug(f"Entering pay_onchain to {address}") try: - self.wait_for_sync() # Check if it's the correct type of SDK object if not isinstance(prepare_response, PreparePayOnchainResponse): logger.error(f"pay_onchain expects PreparePayOnchainResponse object, but received {type(prepare_response)}.") @@ -990,7 +975,6 @@ class PaymentHandler: """ logger.debug("Entering list_refundable_payments") try: - self.wait_for_sync() # Ensure sync before executing refundable_payments = self.instance.list_refundables() logger.debug(f"Found {len(refundable_payments)} refundable payments.") logger.debug("Exiting list_refundable_payments") @@ -1018,7 +1002,6 @@ class PaymentHandler: # Using getattr with a default for logging in case refundable_swap is None or malformed (though type hint should prevent this) logger.debug(f"Entering execute_refund for swap {getattr(refundable_swap, 'swap_address', 'N/A')} to {refund_address} with fee rate {fee_rate_sat_per_vbyte}") try: - self.wait_for_sync() # Check if it's the correct type of SDK object if not isinstance(refundable_swap, RefundableSwap): logger.error(f"execute_refund expects RefundableSwap object, but received {type(refundable_swap)}.") @@ -1062,7 +1045,6 @@ class PaymentHandler: """ logger.debug("Entering rescan_swaps") try: - self.wait_for_sync() # Ensure sync before executing self.instance.rescan_onchain_swaps() logger.info("Onchain swaps rescan initiated.") logger.debug("Exiting rescan_swaps") @@ -1083,7 +1065,6 @@ class PaymentHandler: """ logger.debug("Entering recommended_fees") try: - self.wait_for_sync() # Fee estimates might need network fees = self.instance.recommended_fees() # Assuming recommended_fees returns an object with __dict__ or similar fee structure fees_dict = fees.__dict__ if fees else {} # Convert to dict @@ -1105,7 +1086,6 @@ class PaymentHandler: """ logger.debug("Entering handle_payments_waiting_fee_acceptance") try: - self.wait_for_sync() logger.info("Checking for payments waiting for fee acceptance...") # Filter for WAITING_FEE_ACCEPTANCE state payments_waiting = self.instance.list_payments( @@ -1207,7 +1187,6 @@ class PaymentHandler: """ logger.debug(f"Entering register_webhook with URL: {webhook_url}") try: - self.wait_for_sync() # Might require network # Basic URL format validation (can be made more robust) if not isinstance(webhook_url, str) or not webhook_url.startswith('https://'): logger.warning(f"Invalid webhook_url provided: {webhook_url}") @@ -1230,7 +1209,6 @@ class PaymentHandler: """ logger.debug("Entering unregister_webhook") try: - self.wait_for_sync() # Might require network self.instance.unregister_webhook() logger.info("Webhook unregistered.") logger.debug("Exiting unregister_webhook") @@ -1257,7 +1235,6 @@ class PaymentHandler: # Log truncated message to avoid logging potentially sensitive full messages logger.debug(f"Entering sign_message with message (truncated): {message[:50]}...") try: - self.wait_for_sync() if not isinstance(message, str) or not message: logger.warning("Invalid or empty message provided for signing.") raise ValueError("Message to sign must be a non-empty string.") @@ -1304,7 +1281,6 @@ class PaymentHandler: """ logger.debug(f"Entering check_message for message (truncated): {message[:50]}...") try: - self.wait_for_sync() # Might require network to verify if not isinstance(message, str) or not message: logger.warning("Invalid or empty message provided for checking.") raise ValueError("Message to check must be a non-empty string.") @@ -1343,7 +1319,6 @@ class PaymentHandler: """ logger.debug("Entering fetch_lightning_limits") try: - self.wait_for_sync() limits = self.instance.fetch_lightning_limits() limits_dict = { 'receive': limits.receive.__dict__ if limits.receive else None, @@ -1368,7 +1343,6 @@ class PaymentHandler: """ logger.debug("Entering fetch_onchain_limits") try: - self.wait_for_sync() limits = self.instance.fetch_onchain_limits() limits_dict = { 'receive': limits.receive.__dict__ if limits.receive else None, @@ -1484,4 +1458,46 @@ class PaymentHandler: except Exception as e: logger.error(f"Error checking payment status for {destination[:30]}...: {str(e)}") logger.exception("Full error details:") + raise + + def get_exchange_rate(self, currency: str = None) -> Dict[str, Any]: + """ + Fetches current exchange rates, optionally filtered by currency. + + Args: + currency: Optional currency code (e.g., 'EUR', 'USD'). If provided, returns only that rate. + Returns: + Dictionary containing exchange rates. Format: + If currency specified: {'currency': 'EUR', 'rate': 123.45} + If no currency: {'EUR': 123.45, 'USD': 234.56, ...} + Raises: + ValueError: If specified currency is not found + Exception: For any SDK errors + """ + logger.debug(f"Entering get_exchange_rate for currency: {currency}") + try: + rates = self.instance.fetch_fiat_rates() + rates_dict = {} + + # Convert rates to dictionary + for rate in rates: + rates_dict[rate.coin] = rate.value + + if currency: + currency = currency.upper() + if currency not in rates_dict: + logger.warning(f"Requested currency {currency} not found in available rates") + raise ValueError(f"Exchange rate not available for currency: {currency}") + logger.info(f"Found exchange rate for {currency}: {rates_dict[currency]}") + return { + 'currency': currency, + 'rate': rates_dict[currency] + } + + logger.info(f"Returning all exchange rates for {len(rates_dict)} currencies") + return rates_dict + + except Exception as e: + logger.error(f"Error fetching exchange rate: {str(e)}") + logger.exception("Full error details:") raise \ No newline at end of file From ef48d4567c64a0e5200eafe7d0f2993815aad6ea Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Sat, 17 May 2025 11:52:45 +0200 Subject: [PATCH 5/5] working flow with wordpress --- fly/main.py | 132 ++++++++++++++++++++++++-- fly/nodeless.py | 239 +++++++++++++++++++++--------------------------- 2 files changed, 228 insertions(+), 143 deletions(-) diff --git a/fly/main.py b/fly/main.py index 1ad2299..ae566c8 100644 --- a/fly/main.py +++ b/fly/main.py @@ -1,3 +1,4 @@ +from contextlib import asynccontextmanager from fastapi import FastAPI, Depends, HTTPException, Header, Query, APIRouter from fastapi.security.api_key import APIKeyHeader from pydantic import BaseModel, Field @@ -8,6 +9,9 @@ from enum import Enum from nodeless import PaymentHandler from shopify.router import router as shopify_router import logging +import threading +import asyncio +import time # Set up logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') @@ -15,10 +19,125 @@ logger = logging.getLogger(__name__) load_dotenv() +_payment_handler = None +_handler_lock = threading.Lock() +_sync_task = None +_last_sync_time = 0 +_consecutive_sync_failures = 0 + +async def periodic_sync_check(): + """Background task to periodically check SDK sync status and attempt resync if needed.""" + global _last_sync_time, _consecutive_sync_failures, _payment_handler + + while True: + try: + current_time = time.time() + + if not _payment_handler: + logger.warning("Payment handler not initialized, waiting...") + await asyncio.sleep(5) + continue + + is_synced = _payment_handler.listener.is_synced() + sync_age = current_time - _last_sync_time if _last_sync_time > 0 else float('inf') + + # Log sync status with detailed metrics + logger.info(f"SDK sync status check - Synced: {is_synced}, Last sync age: {sync_age:.1f}s, Consecutive failures: {_consecutive_sync_failures}") + + if not is_synced or sync_age > 30: # Force resync if not synced or sync is older than 30 seconds + logger.warning(f"SDK sync needed - Status: {'Not synced' if not is_synced else 'Sync too old'}") + + # Attempt resync with progressively longer timeouts based on consecutive failures + timeout = min(5 + (_consecutive_sync_failures * 2), 30) # Increase timeout up to 30 seconds + if _payment_handler.wait_for_sync(timeout_seconds=timeout): + logger.info("SDK resync successful") + _last_sync_time = time.time() + _consecutive_sync_failures = 0 + else: + logger.error(f"SDK resync failed after {timeout}s timeout") + _consecutive_sync_failures += 1 + + # If we have too many consecutive failures, try to reinitialize handler + if _consecutive_sync_failures >= 5: + logger.warning("Too many consecutive sync failures, attempting to reinitialize handler...") + try: + with _handler_lock: + _payment_handler.disconnect() + _payment_handler = PaymentHandler() + _consecutive_sync_failures = 0 + logger.info("Payment handler reinitialized successfully") + except Exception as e: + logger.error(f"Failed to reinitialize payment handler: {e}") + else: + _last_sync_time = current_time + _consecutive_sync_failures = 0 + + # Adjust sleep time based on sync status + sleep_time = 10 if not is_synced or _consecutive_sync_failures > 0 else 30 + await asyncio.sleep(sleep_time) + + except Exception as e: + logger.error(f"Error in periodic sync check: {e}") + _consecutive_sync_failures += 1 + await asyncio.sleep(5) # Short sleep on error before retrying + +def get_payment_handler(): + global _payment_handler + if _payment_handler is None: + with _handler_lock: + if _payment_handler is None: + try: + _payment_handler = PaymentHandler() + except Exception as e: + logger.error(f"Failed to initialize PaymentHandler: {str(e)}") + raise HTTPException( + status_code=500, + detail="Failed to initialize payment system" + ) + return _payment_handler + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Lifespan context manager for FastAPI application. + Handles startup and shutdown events. + """ + # Startup + global _payment_handler, _sync_task + try: + _payment_handler = PaymentHandler() + logger.info("Payment system initialized during startup") + + # Start background sync check task + _sync_task = asyncio.create_task(periodic_sync_check()) + logger.info("Background sync check task started") + except Exception as e: + logger.error(f"Failed to initialize payment system during startup: {str(e)}") + # Don't raise here, let the handler initialize on first request if needed + + yield # Server is running + + # Shutdown + if _sync_task: + _sync_task.cancel() + try: + await _sync_task + except asyncio.CancelledError: + pass + logger.info("Background sync check task stopped") + + if _payment_handler: + try: + _payment_handler.disconnect() + logger.info("Payment system disconnected during shutdown") + except Exception as e: + logger.error(f"Error during payment system shutdown: {str(e)}") + app = FastAPI( title="Breez Nodeless Payments API", description="A FastAPI implementation of Breez SDK for Lightning/Liquid payments", - version="1.0.0" + version="1.0.0", + lifespan=lifespan ) API_KEY_NAME = "x-api-key" @@ -132,12 +251,6 @@ async def get_api_key(api_key: str = Header(None, alias=API_KEY_NAME)): ) return api_key -def get_payment_handler(): - try: - return PaymentHandler() - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to initialize PaymentHandler: {str(e)}") - # --- API Endpoints --- @app.get("/list_payments", response_model=PaymentListResponse) async def list_payments( @@ -229,7 +342,10 @@ async def onchain_limits( @app.get("/health") async def health(): - return {"status": "ok"} + global _payment_handler + if _payment_handler and _payment_handler.listener.is_synced(): + return {"status": "ok", "sdk_synced": True} + return {"status": "ok", "sdk_synced": False} @app.get("/check_payment_status/{destination}", response_model=PaymentStatusResponse) async def check_payment_status( diff --git a/fly/nodeless.py b/fly/nodeless.py index fbb6445..d6670ff 100644 --- a/fly/nodeless.py +++ b/fly/nodeless.py @@ -51,6 +51,7 @@ from breez_sdk_liquid import ( import time import logging from pprint import pprint +import threading # Set up logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') @@ -78,7 +79,6 @@ class SdkListener(EventListener): if isinstance(event, SdkEvent.SYNCED): self.synced = True - logger.info("SDK SYNCED") elif isinstance(event, SdkEvent.PAYMENT_SUCCEEDED): details = event.details # Determine identifier based on payment type @@ -140,83 +140,91 @@ class SdkListener(EventListener): class PaymentHandler: """ A wrapper class for the Breez SDK Nodeless (Liquid implementation). - - This class handles SDK initialization, connection, and provides simplified - methods for common payment and wallet operations. + Implements singleton pattern to prevent multiple SDK instances. """ - def __init__(self, network: LiquidNetwork = LiquidNetwork.MAINNET, working_dir: str = '~/.breez-cli', asset_metadata: Optional[List[AssetMetadata]] = None, external_input_parsers: Optional[List[ExternalInputParser]] = None): + _instance = None + _initialized = False + _lock = threading.Lock() + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, network: LiquidNetwork = LiquidNetwork.MAINNET, working_dir: str = '~/.breez-cli', + asset_metadata: Optional[List[AssetMetadata]] = None, + external_input_parsers: Optional[List[ExternalInputParser]] = None): """ Initializes the PaymentHandler and connects to the Breez SDK. - - Args: - network: The Liquid network to use (MAINNET or TESTNET). - working_dir: The directory for SDK files. - asset_metadata: Optional list of AssetMetadata for non-Bitcoin assets. - external_input_parsers: Optional list of ExternalInputParser for custom input parsing. + Uses singleton pattern to prevent multiple initializations. """ - logger.debug("Entering PaymentHandler.__init__") - load_dotenv() # Load environment variables from .env + if self._initialized: + return - self.breez_api_key = os.getenv('BREEZ_API_KEY') - self.seed_phrase = os.getenv('BREEZ_SEED_PHRASE') + with self._lock: + if self._initialized: + return - if not self.breez_api_key: - logger.error("BREEZ_API_KEY not found in environment variables.") - raise Exception("Missing Breez API key in .env file or environment") - if not self.seed_phrase: - logger.error("BREEZ_SEED_PHRASE not found in environment variables.") - raise Exception("Missing seed phrase in .env file or environment") + logger.debug("Initializing PaymentHandler") + load_dotenv() - logger.info("Retrieved credentials from environment successfully") + self.breez_api_key = os.getenv('BREEZ_API_KEY') + self.seed_phrase = os.getenv('BREEZ_SEED_PHRASE') - config = default_config(network, self.breez_api_key) - # Expand user path for working_dir - config.working_dir = os.path.expanduser(working_dir) - # Ensure working directory exists - try: - os.makedirs(config.working_dir, exist_ok=True) - logger.debug(f"Ensured working directory exists: {config.working_dir}") - except OSError as e: - logger.error(f"Failed to create working directory {config.working_dir}: {e}") - raise # Re-raise if directory creation fails + if not self.breez_api_key: + logger.error("BREEZ_API_KEY not found in environment variables.") + raise Exception("Missing Breez API key in .env file or environment") + if not self.seed_phrase: + logger.error("BREEZ_SEED_PHRASE not found in environment variables.") + raise Exception("Missing seed phrase in .env file or environment") - if asset_metadata: - config.asset_metadata = asset_metadata - logger.info(f"Configured asset metadata: {asset_metadata}") + logger.info("Retrieved credentials from environment successfully") - if external_input_parsers: - config.external_input_parsers = external_input_parsers - logger.info(f"Configured external input parsers: {external_input_parsers}") + config = default_config(network, self.breez_api_key) + config.working_dir = os.path.expanduser(working_dir) + + try: + os.makedirs(config.working_dir, exist_ok=True) + except OSError as e: + logger.error(f"Failed to create working directory {config.working_dir}: {e}") + raise - connect_request = ConnectRequest(config=config, mnemonic=self.seed_phrase) + if asset_metadata: + config.asset_metadata = asset_metadata + if external_input_parsers: + config.external_input_parsers = external_input_parsers - try: - self.instance = connect(connect_request) - self.listener = SdkListener() - # Add listener immediately after connecting - self.instance.add_event_listener(self.listener) - logger.info("Breez SDK connected successfully.") - except Exception as e: - logger.error(f"Failed to connect to Breez SDK: {e}") - # Re-raise the exception after logging - raise + connect_request = ConnectRequest(config=config, mnemonic=self.seed_phrase) - logger.debug("Exiting PaymentHandler.__init__") + try: + self.instance = connect(connect_request) + self.listener = SdkListener() + self.instance.add_event_listener(self.listener) + logger.info("Breez SDK connected successfully.") + + # Shorter sync timeout for initial connection + self.wait_for_sync(timeout_seconds=10) + + except Exception as e: + logger.error(f"Failed to connect to Breez SDK: {e}") + raise + self._initialized = True + logger.debug("PaymentHandler initialization complete") - def wait_for_sync(self, timeout_seconds: int = 30): + def wait_for_sync(self, timeout_seconds: int = 10) -> bool: """Wait for the SDK to sync before proceeding.""" - logger.debug(f"Entering wait_for_sync (timeout={timeout_seconds}s)") + logger.debug(f"Waiting for sync (timeout={timeout_seconds}s)") start_time = time.time() while time.time() - start_time < timeout_seconds: if self.listener.is_synced(): - logger.debug("SDK synced.") - logger.debug("Exiting wait_for_sync (synced)") + logger.debug("SDK synced successfully") return True - time.sleep(0.5) # Shorter sleep for faster sync detection - logger.error("Sync timeout: SDK did not sync within the allocated time.") - logger.debug("Exiting wait_for_sync (timeout)") - raise Exception(f"Sync timeout: SDK did not sync within {timeout_seconds} seconds.") + time.sleep(0.1) # Shorter sleep interval + logger.warning("SDK sync timeout") + return False def wait_for_payment(self, identifier: str, timeout_seconds: int = 60) -> bool: """ @@ -1368,96 +1376,57 @@ class PaymentHandler: def check_payment_status(self, destination: str) -> Dict[str, Any]: """ Checks the status of a specific payment by its destination/invoice. - - Args: - destination: The payment destination (invoice) string to check. - Returns: - Dictionary containing payment status information: - { - 'status': str, # The payment status (e.g., 'PENDING', 'SUCCEEDED', 'FAILED') - 'amount_sat': int, # Amount in satoshis - 'fees_sat': int, # Fees paid in satoshis - 'payment_time': int, # Unix timestamp of the payment - 'payment_hash': str, # Payment hash if available - 'error': str, # Error message if any - } - Raises: - ValueError: If destination is invalid. - Exception: For any SDK errors. + Uses optimized status checking with shorter timeouts. """ - logger.debug(f"Entering check_payment_status for destination: {destination[:30]}...") + logger.debug(f"Checking payment status for {destination[:30]}...") try: if not isinstance(destination, str) or not destination: - logger.warning("Invalid or empty destination provided.") - raise ValueError("Destination must be a non-empty string.") + raise ValueError("Invalid destination") - # First check the payment status in our listener's cache - logger.debug("Checking payment status in listener cache...") + # Check cached status first cached_status = self.listener.get_payment_status(destination) - if cached_status: - logger.debug(f"Found cached payment status: {cached_status}") - # If we have a cached final status, we can return it immediately - if cached_status in ['SUCCEEDED', 'FAILED']: - logger.info(f"Returning cached final status: {cached_status}") - return { - 'status': cached_status, - 'amount_sat': None, # These would be None for cached statuses - 'fees_sat': None, - 'payment_time': None, - 'payment_hash': None, - 'error': None - } - - # If no cached final status, check the actual payments - logger.debug("No cached final status found, querying payment list...") - try: - payments = self.instance.list_payments(ListPaymentsRequest()) - logger.debug(f"Found {len(payments)} total payments") - except Exception as e: - logger.error(f"Error querying payment list: {e}") - raise - - # Find the most recent payment matching the destination - matching_payment = None - for payment in payments: - logger.debug(f"Checking payment: {getattr(payment, 'destination', 'No destination')} == {destination}") - if hasattr(payment, 'destination') and payment.destination == destination: - if matching_payment is None or payment.timestamp > matching_payment.timestamp: - matching_payment = payment - logger.debug("Found matching payment or found more recent matching payment") - - if matching_payment: - # Extract payment details - status = str(matching_payment.status) - details = matching_payment.details - - result = { - 'status': status, - 'amount_sat': matching_payment.amount_sat, - 'fees_sat': matching_payment.fees_sat, - 'payment_time': matching_payment.timestamp, - 'payment_hash': getattr(details, 'payment_hash', None), - 'error': getattr(matching_payment, 'error', None) - } - - logger.info(f"Found payment status for {destination[:30]}...: {status}") - logger.debug(f"Payment details: {result}") - logger.debug("Exiting check_payment_status") - return result - else: - logger.info(f"No payment found for destination: {destination[:30]}...") + if cached_status in ['SUCCEEDED', 'FAILED']: return { - 'status': 'NOT_FOUND', + 'status': cached_status, 'amount_sat': None, 'fees_sat': None, 'payment_time': None, 'payment_hash': None, - 'error': 'Payment not found' + 'error': None } + # Short wait for payment status + payment_succeeded = self.wait_for_payment(destination, timeout_seconds=2) + + # Get final status + final_status = self.listener.get_payment_status(destination) or 'PENDING' + status = 'SUCCEEDED' if payment_succeeded else final_status + + # Try to get payment details + try: + payments = self.instance.list_payments(ListPaymentsRequest()) + payment = next( + (p for p in payments if hasattr(p, 'destination') and p.destination == destination), + None + ) + except Exception as e: + logger.warning(f"Could not fetch payment details: {e}") + payment = None + + result = { + 'status': status, + 'amount_sat': getattr(payment, 'amount_sat', None), + 'fees_sat': getattr(payment, 'fees_sat', None), + 'payment_time': getattr(payment, 'timestamp', None), + 'payment_hash': getattr(payment.details, 'payment_hash', None) if payment and payment.details else None, + 'error': None if status == 'SUCCEEDED' else getattr(payment, 'error', 'Payment details not found') + } + + logger.info(f"Payment status: {status}") + return result + except Exception as e: - logger.error(f"Error checking payment status for {destination[:30]}...: {str(e)}") - logger.exception("Full error details:") + logger.error(f"Error checking payment status: {str(e)}") raise def get_exchange_rate(self, currency: str = None) -> Dict[str, Any]: