mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-19 18:14:19 +01:00
295 lines
9.4 KiB
Python
295 lines
9.4 KiB
Python
import secrets
|
|
from typing import AsyncGenerator, Dict, Optional, Union
|
|
|
|
import httpx
|
|
from pydantic import BaseModel
|
|
|
|
from ..core.base import Amount, MeltQuote, Unit
|
|
from ..core.models import PostMeltQuoteRequest
|
|
from ..core.settings import settings
|
|
from .base import (
|
|
InvoiceResponse,
|
|
LightningBackend,
|
|
PaymentQuoteResponse,
|
|
PaymentResponse,
|
|
PaymentResult,
|
|
PaymentStatus,
|
|
StatusResponse,
|
|
)
|
|
|
|
USDT = "USDT"
|
|
|
|
|
|
class StrikeAmount(BaseModel):
|
|
amount: str
|
|
currency: str
|
|
|
|
|
|
class StrikeRate(BaseModel):
|
|
amount: str
|
|
sourceCurrency: str
|
|
targetCurrency: str
|
|
|
|
|
|
class StrikeCreateInvoiceResponse(BaseModel):
|
|
invoiceId: str
|
|
amount: StrikeAmount
|
|
state: str
|
|
description: str
|
|
|
|
|
|
class StrikePaymentQuoteResponse(BaseModel):
|
|
lightningNetworkFee: StrikeAmount
|
|
paymentQuoteId: str
|
|
validUntil: str
|
|
amount: StrikeAmount
|
|
totalFee: StrikeAmount
|
|
totalAmount: StrikeAmount
|
|
|
|
|
|
class InvoiceQuoteResponse(BaseModel):
|
|
quoteId: str
|
|
description: str
|
|
lnInvoice: str
|
|
expiration: str
|
|
expirationInSec: int
|
|
targetAmount: StrikeAmount
|
|
sourceAmount: StrikeAmount
|
|
conversionRate: StrikeRate
|
|
|
|
|
|
class StrikePaymentResponse(BaseModel):
|
|
paymentId: str
|
|
state: str
|
|
result: str
|
|
completed: Optional[str]
|
|
delivered: Optional[str]
|
|
amount: StrikeAmount
|
|
totalFee: StrikeAmount
|
|
lightningNetworkFee: StrikeAmount
|
|
totalAmount: StrikeAmount
|
|
lightning: Dict[str, StrikeAmount]
|
|
|
|
|
|
PAYMENT_RESULT_MAP = {
|
|
"PENDING": PaymentResult.PENDING,
|
|
"COMPLETED": PaymentResult.SETTLED,
|
|
"FAILED": PaymentResult.FAILED,
|
|
}
|
|
|
|
|
|
INVOICE_RESULT_MAP = {
|
|
"PENDING": PaymentResult.PENDING,
|
|
"UNPAID": PaymentResult.PENDING,
|
|
"PAID": PaymentResult.SETTLED,
|
|
"CANCELLED": PaymentResult.FAILED,
|
|
}
|
|
|
|
|
|
class StrikeWallet(LightningBackend):
|
|
"""https://docs.strike.me/api/"""
|
|
|
|
supported_units = {Unit.sat, Unit.msat, Unit.usd, Unit.eur}
|
|
supports_description: bool = False
|
|
currency_map = {Unit.sat: "BTC", Unit.msat: "BTC", Unit.usd: "USD", Unit.eur: "EUR"}
|
|
|
|
def fee_int(
|
|
self,
|
|
strike_quote: Union[StrikePaymentQuoteResponse, StrikePaymentResponse],
|
|
unit: Unit,
|
|
) -> int:
|
|
fee_str = strike_quote.totalFee.amount
|
|
fee: int = 0
|
|
if strike_quote.totalFee.currency == self.currency_map[Unit.sat]:
|
|
if unit == Unit.sat:
|
|
fee = int(float(fee_str) * 1e8)
|
|
elif unit == Unit.msat:
|
|
fee = int(float(fee_str) * 1e11)
|
|
elif strike_quote.totalFee.currency in [
|
|
self.currency_map[Unit.usd],
|
|
self.currency_map[Unit.eur],
|
|
USDT,
|
|
]:
|
|
fee = int(float(fee_str) * 100)
|
|
else:
|
|
raise Exception(
|
|
f"Unexpected currency {strike_quote.totalFee.currency} in fee"
|
|
)
|
|
return fee
|
|
|
|
def __init__(self, unit: Unit, **kwargs):
|
|
self.assert_unit_supported(unit)
|
|
self.unit = unit
|
|
self.endpoint = "https://api.strike.me"
|
|
self.currency = self.currency_map[self.unit]
|
|
|
|
# bearer auth with settings.mint_strike_key
|
|
bearer_auth = {
|
|
"Authorization": f"Bearer {settings.mint_strike_key}",
|
|
}
|
|
self.client = httpx.AsyncClient(
|
|
verify=not settings.debug,
|
|
headers=bearer_auth,
|
|
timeout=None,
|
|
)
|
|
|
|
async def status(self) -> StatusResponse:
|
|
try:
|
|
r = await self.client.get(url=f"{self.endpoint}/v1/balances", timeout=15)
|
|
r.raise_for_status()
|
|
except Exception as exc:
|
|
return StatusResponse(
|
|
error_message=f"Failed to connect to {self.endpoint} due to: {exc}",
|
|
balance=Amount(self.unit, 0),
|
|
)
|
|
|
|
try:
|
|
data = r.json()
|
|
except Exception:
|
|
return StatusResponse(
|
|
error_message=(
|
|
f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'"
|
|
),
|
|
balance=Amount(self.unit, 0),
|
|
)
|
|
|
|
for balance in data:
|
|
if balance["currency"] == self.currency:
|
|
return StatusResponse(
|
|
error_message=None,
|
|
balance=Amount.from_float(float(balance["total"]), self.unit),
|
|
)
|
|
|
|
# if the unit is USD but no USD balance was found, we try USDT
|
|
if self.unit == Unit.usd:
|
|
for balance in data:
|
|
if balance["currency"] == USDT:
|
|
self.currency = USDT
|
|
return StatusResponse(
|
|
error_message=None,
|
|
balance=Amount.from_float(float(balance["total"]), self.unit),
|
|
)
|
|
|
|
return StatusResponse(
|
|
error_message=f"Could not find balance for currency {self.currency}",
|
|
balance=Amount(self.unit, 0),
|
|
)
|
|
|
|
async def create_invoice(
|
|
self,
|
|
amount: Amount,
|
|
memo: Optional[str] = None,
|
|
description_hash: Optional[bytes] = None,
|
|
unhashed_description: Optional[bytes] = None,
|
|
) -> InvoiceResponse:
|
|
self.assert_unit_supported(amount.unit)
|
|
|
|
payload = {
|
|
"correlationId": secrets.token_hex(16),
|
|
"description": memo or "Invoice for order 123",
|
|
"amount": {"amount": amount.to_float_string(), "currency": self.currency},
|
|
}
|
|
try:
|
|
r = await self.client.post(url=f"{self.endpoint}/v1/invoices", json=payload)
|
|
r.raise_for_status()
|
|
except Exception:
|
|
return InvoiceResponse(ok=False, error_message=r.json()["detail"])
|
|
|
|
invoice = StrikeCreateInvoiceResponse.parse_obj(r.json())
|
|
|
|
try:
|
|
payload = {"descriptionHash": secrets.token_hex(32)}
|
|
r2 = await self.client.post(
|
|
f"{self.endpoint}/v1/invoices/{invoice.invoiceId}/quote", json=payload
|
|
)
|
|
r2.raise_for_status()
|
|
except Exception:
|
|
return InvoiceResponse(ok=False, error_message=r.json()["detail"])
|
|
|
|
quote = InvoiceQuoteResponse.parse_obj(r2.json())
|
|
return InvoiceResponse(
|
|
ok=True, checking_id=invoice.invoiceId, payment_request=quote.lnInvoice
|
|
)
|
|
|
|
async def get_payment_quote(
|
|
self, melt_quote: PostMeltQuoteRequest
|
|
) -> PaymentQuoteResponse:
|
|
bolt11 = melt_quote.request
|
|
try:
|
|
r = await self.client.post(
|
|
url=f"{self.endpoint}/v1/payment-quotes/lightning",
|
|
json={"sourceCurrency": self.currency, "lnInvoice": bolt11},
|
|
timeout=None,
|
|
)
|
|
r.raise_for_status()
|
|
except Exception:
|
|
error_message = r.json()["data"]["message"]
|
|
raise Exception(error_message)
|
|
strike_quote = StrikePaymentQuoteResponse.parse_obj(r.json())
|
|
if strike_quote.amount.currency != self.currency:
|
|
raise Exception(
|
|
f"Expected currency {self.currency}, got {strike_quote.amount.currency}"
|
|
)
|
|
amount = Amount.from_float(float(strike_quote.amount.amount), self.unit)
|
|
fee = self.fee_int(strike_quote, self.unit)
|
|
|
|
quote = PaymentQuoteResponse(
|
|
amount=amount,
|
|
checking_id=strike_quote.paymentQuoteId,
|
|
fee=Amount(self.unit, fee),
|
|
)
|
|
return quote
|
|
|
|
async def pay_invoice(
|
|
self, quote: MeltQuote, fee_limit_msat: int
|
|
) -> PaymentResponse:
|
|
# we need to get the checking_id of this quote
|
|
try:
|
|
r = await self.client.patch(
|
|
url=f"{self.endpoint}/v1/payment-quotes/{quote.checking_id}/execute",
|
|
timeout=None,
|
|
)
|
|
r.raise_for_status()
|
|
except Exception:
|
|
error_message = r.json()["data"]["message"]
|
|
return PaymentResponse(
|
|
result=PaymentResult.FAILED, error_message=error_message
|
|
)
|
|
|
|
payment = StrikePaymentResponse.parse_obj(r.json())
|
|
fee = self.fee_int(payment, self.unit)
|
|
return PaymentResponse(
|
|
result=PAYMENT_RESULT_MAP[payment.state],
|
|
checking_id=payment.paymentId,
|
|
fee=Amount(self.unit, fee),
|
|
)
|
|
|
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
|
try:
|
|
r = await self.client.get(url=f"{self.endpoint}/v1/invoices/{checking_id}")
|
|
r.raise_for_status()
|
|
except Exception as e:
|
|
return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e))
|
|
data = r.json()
|
|
return PaymentStatus(result=INVOICE_RESULT_MAP[data.get("state")])
|
|
|
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
|
try:
|
|
r = await self.client.get(url=f"{self.endpoint}/v1/payments/{checking_id}")
|
|
r.raise_for_status()
|
|
payment = StrikePaymentResponse.parse_obj(r.json())
|
|
fee = self.fee_int(payment, self.unit)
|
|
return PaymentStatus(
|
|
result=PAYMENT_RESULT_MAP[payment.state],
|
|
fee=Amount(self.unit, fee),
|
|
)
|
|
except httpx.HTTPStatusError as exc:
|
|
if exc.response.status_code != 404:
|
|
raise exc
|
|
return PaymentStatus(
|
|
result=PaymentResult.UNKNOWN, error_message=exc.response.text
|
|
)
|
|
|
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: # type: ignore
|
|
raise NotImplementedError("paid_invoices_stream not implemented")
|