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"