This commit is contained in:
2025-05-22 10:33:35 +02:00
parent d490c8e3f7
commit 0cbad40da3
4 changed files with 350 additions and 1533 deletions

View File

@@ -1,4 +1,4 @@
FROM ubuntu:24.04 FROM python:3.12
WORKDIR /app WORKDIR /app

View File

@@ -175,11 +175,11 @@ class SendOnchainBody(BaseModel):
class PaymentResponse(BaseModel): class PaymentResponse(BaseModel):
timestamp: int timestamp: int
amount_sat: int amount_sat: int = 0 # Default to 0 instead of requiring it
fees_sat: int fees_sat: int = 0 # Default to 0 instead of requiring it
payment_type: str payment_type: str = "UNKNOWN" # Default for NOT_FOUND cases
status: str status: str
details: Any details: Dict[str, Any] = {} # Default to empty dict instead of requiring it
destination: Optional[str] = None destination: Optional[str] = None
tx_id: Optional[str] = None tx_id: Optional[str] = None
payment_hash: Optional[str] = None payment_hash: Optional[str] = None
@@ -206,11 +206,11 @@ class SendOnchainResponse(BaseModel):
class PaymentStatusResponse(BaseModel): class PaymentStatusResponse(BaseModel):
status: str status: str
payment_details: Optional[Dict[str, Any]] = None
error: Optional[str] = None
timestamp: Optional[int] = None
amount_sat: Optional[int] = None amount_sat: Optional[int] = None
fees_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 # LNURL Models
class ParseInputBody(BaseModel): class ParseInputBody(BaseModel):
@@ -354,31 +354,31 @@ async def check_payment_status(
handler: PaymentHandler = Depends(get_payment_handler) handler: PaymentHandler = Depends(get_payment_handler)
): ):
""" """
Check the status of a payment by its destination/invoice. Check the status of a payment by its identifier (payment hash, destination, or swap ID).
The payment states follow the SDK states directly:
- PENDING: Swap service is holding payment, lockup transaction broadcast
- WAITING_CONFIRMATION: Claim transaction broadcast or direct Liquid transaction seen
- SUCCEEDED: Claim transaction or direct Liquid transaction confirmed
- FAILED: Swap failed (expired or lockup transaction failed)
- WAITING_FEE_ACCEPTANCE: Payment requires fee acceptance
Args: Args:
destination: The payment destination (invoice) to check destination: The payment identifier (payment hash, destination, or swap ID)
Returns: Returns:
Payment status information including status, amount, fees, and timestamps Payment status information including status, payment details, amount, fees, and timestamps
Raises:
HTTPException: 404 if payment not found, 500 for other errors
""" """
logger.info(f"Received payment status check request for destination: {destination[:30]}...") logger.info(f"Received payment status check request for identifier: {destination[:30]}...")
try: try:
logger.debug("Initializing PaymentHandler...")
logger.debug(f"Handler instance: {handler}")
logger.debug("Calling check_payment_status method...")
result = handler.check_payment_status(destination) result = handler.check_payment_status(destination)
logger.info(f"Payment status check successful. Status: {result.get('status', 'unknown')}") logger.info(f"Payment status check successful. Status: {result.get('status', 'unknown')}")
logger.debug(f"Full result: {result}") logger.debug(f"Full result: {result}")
return result return result
except ValueError as e: except ValueError as e:
logger.error(f"Validation error in check_payment_status: {str(e)}") logger.warning(f"Payment not found: {str(e)}")
logger.exception("Validation error details:") raise HTTPException(status_code=404, detail=str(e))
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: except Exception as e:
logger.error(f"Unexpected error in check_payment_status: {str(e)}") logger.error(f"Unexpected error in check_payment_status: {str(e)}")
logger.exception("Full error details:") logger.exception("Full error details:")
@@ -511,6 +511,74 @@ async def get_all_exchange_rates(
logger.exception("Full error details:") logger.exception("Full error details:")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/payment/{payment_id}", response_model=PaymentResponse)
async def get_payment_info(
payment_id: str,
api_key: str = Depends(get_api_key),
handler: PaymentHandler = Depends(get_payment_handler)
):
"""
Get detailed payment information for a specific BOLT11 invoice.
Args:
payment_id: The BOLT11 invoice string
Returns:
Complete payment information if found, or a payment object with NOT_FOUND status
Raises:
HTTPException: 400 if invalid invoice, 500 for unexpected errors
"""
logger.debug(f"Received payment info request for invoice: {payment_id[:30]}...")
try:
# Parse the input to verify it's a valid BOLT11 invoice
try:
parsed = handler.parse_input(payment_id)
if not parsed.get('type') == 'BOLT11':
logger.warning(f"Invalid payment ID format: {payment_id[:30]}...")
raise HTTPException(
status_code=400,
detail="Invalid payment ID: Must be a BOLT11 invoice"
)
except Exception as e:
logger.warning(f"Failed to parse payment ID: {str(e)}")
raise HTTPException(
status_code=400,
detail=f"Invalid BOLT11 invoice: {str(e)}"
)
# List all payments and find the matching one
payments = handler.list_payments({})
for payment in payments:
# Check both the destination and payment hash
if (payment.get('destination') == payment_id or
payment.get('payment_hash') == parsed.get('invoice', {}).get('payment_hash')):
logger.debug(f"Found payment with status: {payment.get('status', 'unknown')}")
return payment
# If we get here, payment was not found - return a payment object with NOT_FOUND status
logger.debug(f"No payment found for invoice: {payment_id[:30]}...")
payment_hash = parsed.get('invoice', {}).get('payment_hash')
return {
'status': 'NOT_FOUND',
'payment_type': 'UNKNOWN',
'amount_sat': 0,
'fees_sat': 0,
'timestamp': int(time.time()),
'details': {},
'payment_hash': payment_hash,
'destination': payment_id,
'tx_id': None,
'swap_id': None
}
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
# Log unexpected errors
logger.error(f"Unexpected error retrieving payment info: {str(e)}")
logger.exception("Full error details:")
raise HTTPException(status_code=500, detail=str(e))
app.include_router(ln_router) app.include_router(ln_router)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -17,36 +17,31 @@ from breez_sdk_liquid import (
default_config, default_config,
PaymentMethod, PaymentMethod,
ListPaymentsRequest, ListPaymentsRequest,
InputType, # Added for parse functionality InputType,
SignMessageRequest, # Added for message signing SignMessageRequest,
CheckMessageRequest, # Added for message checking CheckMessageRequest,
BuyBitcoinProvider, # Added for buy bitcoin BuyBitcoinProvider,
PrepareBuyBitcoinRequest, # Added for buy bitcoin PrepareBuyBitcoinRequest,
BuyBitcoinRequest, # Added for buy bitcoin BuyBitcoinRequest,
PreparePayOnchainRequest, # Added for pay onchain PreparePayOnchainRequest,
PayOnchainRequest, # Added for pay onchain PayOnchainRequest,
RefundRequest, # Added for refunds RefundRequest,
RefundableSwap, # Added for refunds RefundableSwap,
FetchPaymentProposedFeesRequest, # Added for fee acceptance FetchPaymentProposedFeesRequest,
AcceptPaymentProposedFeesRequest, # Added for fee acceptance AcceptPaymentProposedFeesRequest,
PaymentState, # Added for fee acceptance PaymentState,
PaymentDetails, # Added for fee acceptance PaymentDetails,
AssetMetadata, # Added for assets AssetMetadata,
ExternalInputParser, # Added for parsers ExternalInputParser,
GetPaymentRequest, # Added for get payment GetPaymentRequest,
ListPaymentDetails, # Added for list payments by details ListPaymentDetails,
ReceiveAmount, # Ensure ReceiveAmount is in this list! ReceiveAmount,
# --- Imports for refined method signatures ---
PrepareBuyBitcoinResponse, PrepareBuyBitcoinResponse,
PrepareLnUrlPayResponse, PrepareLnUrlPayResponse,
PreparePayOnchainResponse, PreparePayOnchainResponse,
# Correct Imports for LNURL Data Objects LnUrlPayRequestData,
LnUrlPayRequestData, # Corrected import for prepare_lnurl_pay LnUrlAuthRequestData,
LnUrlAuthRequestData, # Corrected import for lnurl_auth LnUrlWithdrawRequestData,
LnUrlWithdrawRequestData, # Corrected import for lnurl_withdraw
# RefundableSwap already imported
# --- End imports ---
) )
import time import time
import logging import logging
@@ -63,67 +58,112 @@ class SdkListener(EventListener):
A listener class for handling Breez SDK events. A listener class for handling Breez SDK events.
This class extends the EventListener from breez_sdk_liquid and implements This class extends the EventListener from breez_sdk_liquid and implements
custom event handling logic, particularly for tracking successful payments custom event handling logic for tracking payment states through their lifecycle:
and other key SDK events.
Lightning Payment States:
- PENDING: The swap service is holding the payment and has broadcast a lockup transaction
- WAITING_CONFIRMATION: Claim transaction broadcast or direct Liquid transaction seen
- SUCCEEDED: Claim transaction or direct Liquid transaction confirmed
- FAILED: Swap failed (expired or lockup transaction failed)
- WAITING_FEE_ACCEPTANCE: Payment requires fee acceptance
""" """
def __init__(self): def __init__(self):
self.synced = False self.synced = False
self.paid = [] self.paid = [] # Legacy list for backward compatibility
self.refunded = [] # Added for tracking refunds self.refunded = [] # Track refunded payments
self.payment_statuses = {} # Track statuses for better payment checking self.payment_statuses = {} # Track all payment statuses
self.payment_errors = {} # Track error messages for failed payments
self.payment_timestamps = {} # Track when payments change state
self.payment_details = {} # Cache payment details
def _update_payment_state(self, identifier: str, status: str, details: Any = None, error: str = None):
"""Helper method to update payment state and related tracking."""
if not identifier:
logger.warning(f"Attempted to update payment state with empty identifier. Status: {status}")
return
# Update status and timestamp
self.payment_statuses[identifier] = status
self.payment_timestamps[identifier] = int(time.time())
# Cache payment details if provided
if details:
self.payment_details[identifier] = details
# Track errors for failed payments
if error:
self.payment_errors[identifier] = error
elif status != 'FAILED' and identifier in self.payment_errors:
del self.payment_errors[identifier]
# Update paid list for backward compatibility
if status in ['WAITING_CONFIRMATION', 'SUCCEEDED']:
if identifier not in self.paid:
self.paid.append(identifier)
logger.info(f"Payment {identifier} added to paid list (status: {status})")
# Log state change
logger.info(f"Payment {identifier} state updated to {status}" +
(f" with error: {error}" if error else ""))
def on_event(self, event): def on_event(self, event):
"""Handles incoming SDK events.""" """Handles incoming SDK events."""
# Log all events at debug level
logger.debug(f"Received SDK event: {event}") logger.debug(f"Received SDK event: {event}")
if isinstance(event, SdkEvent.SYNCED): if isinstance(event, SdkEvent.SYNCED):
self.synced = True self.synced = True
logger.info("SDK synced")
return
# Extract payment details and identifier
details = getattr(event, 'details', None)
if not details:
logger.debug("Event received without details")
return
# Determine payment identifier (try multiple possible fields)
identifier = None
if hasattr(details, 'payment_hash') and details.payment_hash:
identifier = details.payment_hash
elif hasattr(details, 'destination') and details.destination:
identifier = details.destination
elif hasattr(details, 'swap_id') and details.swap_id:
identifier = details.swap_id
if not identifier:
logger.warning("Could not determine payment identifier from event")
return
# Handle different payment events
if isinstance(event, SdkEvent.PAYMENT_PENDING):
self._update_payment_state(identifier, 'PENDING', details)
logger.info(f"Payment {identifier} is pending (lockup transaction broadcast)")
elif isinstance(event, SdkEvent.PAYMENT_WAITING_CONFIRMATION):
self._update_payment_state(identifier, 'WAITING_CONFIRMATION', details)
logger.info(f"Payment {identifier} is waiting confirmation (claim tx broadcast)")
elif isinstance(event, SdkEvent.PAYMENT_SUCCEEDED): elif isinstance(event, SdkEvent.PAYMENT_SUCCEEDED):
details = event.details self._update_payment_state(identifier, 'SUCCEEDED', details)
# Determine identifier based on payment type logger.info(f"Payment {identifier} succeeded (claim tx confirmed)")
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): 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') error = getattr(details, 'error', 'Unknown error')
self._update_payment_state(identifier, 'FAILED', details, error)
logger.error(f"Payment {identifier} failed. Error: {error}")
if identifier: elif isinstance(event, SdkEvent.PAYMENT_WAITING_FEE_ACCEPTANCE):
self.payment_statuses[identifier] = 'FAILED' self._update_payment_state(identifier, 'WAITING_FEE_ACCEPTANCE', details)
logger.error(f"PAYMENT FAILED for identifier: {identifier}, Error: {error}") logger.info(f"Payment {identifier} is waiting for fee acceptance")
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: 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 Checks if a payment to a specific destination has succeeded.
return destination in self.paid or self.payment_statuses.get(destination) == 'SUCCEEDED' Now considers both WAITING_CONFIRMATION and SUCCEEDED as successful states.
"""
status = self.payment_statuses.get(destination)
return (destination in self.paid or
status in ['WAITING_CONFIRMATION', 'SUCCEEDED'])
def is_synced(self) -> bool: def is_synced(self) -> bool:
"""Checks if the SDK is synced.""" """Checks if the SDK is synced."""
@@ -132,10 +172,46 @@ class SdkListener(EventListener):
def get_payment_status(self, identifier: str) -> Optional[str]: def get_payment_status(self, identifier: str) -> Optional[str]:
""" """
Get the known status for a payment identified by destination, hash, or swap ID. Get the known status for a payment identified by destination, hash, or swap ID.
Returns status string ('SUCCEEDED', 'FAILED', 'REFUNDED', 'PENDING', etc.) or None. Returns status string ('SUCCEEDED', 'FAILED', 'PENDING', etc.) or None.
""" """
return self.payment_statuses.get(identifier) return self.payment_statuses.get(identifier)
def get_payment_error(self, identifier: str) -> Optional[str]:
"""Get the error message for a failed payment, if any."""
return self.payment_errors.get(identifier)
def get_payment_timestamp(self, identifier: str) -> Optional[int]:
"""Get the timestamp of the last state change for a payment."""
return self.payment_timestamps.get(identifier)
def get_payment_details(self, identifier: str) -> Optional[Any]:
"""Get cached payment details if available."""
return self.payment_details.get(identifier)
def clear_old_data(self, max_age_seconds: int = 86400):
"""
Clear payment data older than max_age_seconds (default 24 hours).
This helps prevent memory growth from old payment data.
"""
current_time = int(time.time())
old_identifiers = [
identifier for identifier, timestamp in self.payment_timestamps.items()
if current_time - timestamp > max_age_seconds
]
for identifier in old_identifiers:
self.payment_statuses.pop(identifier, None)
self.payment_errors.pop(identifier, None)
self.payment_timestamps.pop(identifier, None)
self.payment_details.pop(identifier, None)
if identifier in self.paid:
self.paid.remove(identifier)
if identifier in self.refunded:
self.refunded.remove(identifier)
if old_identifiers:
logger.info(f"Cleared {len(old_identifiers)} old payment records")
class PaymentHandler: class PaymentHandler:
""" """
@@ -235,9 +311,9 @@ class PaymentHandler:
start_time = time.time() start_time = time.time()
while time.time() - start_time < timeout_seconds: while time.time() - start_time < timeout_seconds:
status = self.listener.get_payment_status(identifier) status = self.listener.get_payment_status(identifier)
if status == 'SUCCEEDED': if status in ['SUCCEEDED', 'PENDING']:
logger.debug(f"Payment for {identifier} succeeded.") logger.debug(f"Payment for {identifier} has status: {status}")
logger.debug("Exiting wait_for_payment (succeeded)") logger.debug("Exiting wait_for_payment (succeeded or pending)")
return True return True
if status == 'FAILED': if status == 'FAILED':
logger.error(f"Payment for {identifier} failed during wait.") logger.error(f"Payment for {identifier} failed during wait.")
@@ -1373,58 +1449,123 @@ class PaymentHandler:
return {k: self.sdk_to_dict(v) for k, v in obj.__dict__.items()} return {k: self.sdk_to_dict(v) for k, v in obj.__dict__.items()}
return str(obj) # fallback return str(obj) # fallback
def check_payment_status(self, destination: str) -> Dict[str, Any]: def check_payment_status(self, payment_identifier: str) -> Dict[str, Any]:
""" """
Checks the status of a specific payment by its destination/invoice. Checks the status of a payment by its identifier (payment hash, destination, or swap ID).
Uses optimized status checking with shorter timeouts. For WooCommerce integration, we consider both WAITING_CONFIRMATION and SUCCEEDED as successful states
""" since WAITING_CONFIRMATION means the payment is irreversible (just waiting for onchain confirmation).
logger.debug(f"Checking payment status for {destination[:30]}...")
try:
if not isinstance(destination, str) or not destination:
raise ValueError("Invalid destination")
# Check cached status first The payment states follow the SDK states directly:
cached_status = self.listener.get_payment_status(destination) - PENDING: Swap service is holding payment, lockup transaction broadcast
if cached_status in ['SUCCEEDED', 'FAILED']: - WAITING_CONFIRMATION: Claim transaction broadcast or direct Liquid transaction seen (considered successful)
- SUCCEEDED: Claim transaction or direct Liquid transaction confirmed
- FAILED: Swap failed (expired or lockup transaction failed)
- WAITING_FEE_ACCEPTANCE: Payment requires fee acceptance
- UNKNOWN: Payment not found or status cannot be determined
Args:
payment_identifier: Payment hash, destination, or swap ID string.
Returns:
Dictionary containing:
- status: Current payment state from SDK
- payment_details: Full payment details if available
- error: Error message if payment failed
- timestamp: When the payment was initiated/completed
- amount_sat: Payment amount in satoshis
- fees_sat: Payment fees in satoshis
"""
logger.debug(f"Checking payment status for identifier: {payment_identifier}")
try:
if not isinstance(payment_identifier, str) or not payment_identifier:
raise ValueError("Invalid payment identifier")
# Always try to get fresh SDK status first for new payments
payment = None
try:
payment = self.instance.get_payment(GetPaymentRequest.PAYMENT_HASH(payment_identifier))
if payment:
status = str(payment.status)
# Update our internal tracking
self.listener.payment_statuses[payment_identifier] = status
# If payment is in a final state, add to paid list if successful
if status in ['WAITING_CONFIRMATION', 'SUCCEEDED']:
if payment_identifier not in self.listener.paid:
self.listener.paid.append(payment_identifier)
logger.info(f"Payment {payment_identifier} marked as paid (status: {status})")
return {
'status': status,
'payment_details': self.sdk_to_dict(payment),
'error': None if status not in ['FAILED'] else 'Payment failed',
'timestamp': payment.timestamp,
'amount_sat': payment.amount_sat,
'fees_sat': payment.fees_sat
}
except Exception as e:
logger.debug(f"Payment hash lookup failed: {str(e)}")
# Try swap ID lookup if payment hash lookup failed
try:
payment = self.instance.get_payment(GetPaymentRequest.SWAP_ID(payment_identifier))
if payment:
status = str(payment.status)
# Update our internal tracking
self.listener.payment_statuses[payment_identifier] = status
# If payment is in a final state, add to paid list if successful
if status in ['WAITING_CONFIRMATION', 'SUCCEEDED']:
if payment_identifier not in self.listener.paid:
self.listener.paid.append(payment_identifier)
logger.info(f"Payment {payment_identifier} marked as paid (status: {status})")
return {
'status': status,
'payment_details': self.sdk_to_dict(payment),
'error': None if status not in ['FAILED'] else 'Payment failed',
'timestamp': payment.timestamp,
'amount_sat': payment.amount_sat,
'fees_sat': payment.fees_sat
}
except Exception as e:
logger.debug(f"Swap ID lookup failed: {str(e)}")
# If we couldn't get fresh status, check our internal state
# This helps with payments we've seen before but might temporarily fail to fetch
if payment_identifier in self.listener.paid:
logger.debug(f"Found payment in internal paid list: {payment_identifier}")
return { return {
'status': cached_status, 'status': 'SUCCEEDED', # We consider it succeeded if it was in paid list
'payment_details': None,
'error': None,
'timestamp': None,
'amount_sat': None, 'amount_sat': None,
'fees_sat': None, 'fees_sat': None
'payment_time': None,
'payment_hash': None,
'error': None
} }
# Short wait for payment status # Check cached status as last resort
payment_succeeded = self.wait_for_payment(destination, timeout_seconds=2) cached_status = self.listener.get_payment_status(payment_identifier)
if cached_status:
logger.debug(f"Using cached status: {cached_status}")
return {
'status': cached_status,
'payment_details': None,
'error': None if cached_status not in ['FAILED'] else 'Payment failed',
'timestamp': None,
'amount_sat': None,
'fees_sat': None
}
# Get final status # If we get here, we couldn't find the payment
final_status = self.listener.get_payment_status(destination) or 'PENDING' logger.debug(f"No payment found for identifier: {payment_identifier}")
status = 'SUCCEEDED' if payment_succeeded else final_status return {
'status': 'UNKNOWN',
# Try to get payment details 'payment_details': None,
try: 'error': 'Payment not found',
payments = self.instance.list_payments(ListPaymentsRequest()) 'timestamp': None,
payment = next( 'amount_sat': None,
(p for p in payments if hasattr(p, 'destination') and p.destination == destination), 'fees_sat': None
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: except Exception as e:
logger.error(f"Error checking payment status: {str(e)}") logger.error(f"Error checking payment status: {str(e)}")
raise raise

File diff suppressed because it is too large Load Diff