mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-20 18:44:20 +01:00
* wip store balance * store balances in watchdog worker * move mint_auth_database setting * auth db * balances returned as Amount (instead of int) * add test for balance change on invoice receive * fix 1 test * cancel tasks on shutdown * watchdog can now abort * remove wallet api server * fix lndgrpc * fix lnbits balance * disable watchdog * balance lnbits msat * test db watcher with its own database connection * init superclass only once * wip: log balance in keysets table * check max balance using new keyset balance * fix test * fix another test * store fees in keysets * format * cleanup * shorter * add keyset migration to auth server * fix fakewallet * fix db tests * fix postgres problems during migration 26 (mint) * fix cln * ledger * working with pending * super fast watchdog, errors * test new pipeline * delete walletapi * delete unneeded files * revert workflows
486 lines
16 KiB
Python
486 lines
16 KiB
Python
import json
|
|
import math
|
|
from typing import AsyncGenerator, Dict, Optional, Union
|
|
|
|
import bolt11
|
|
import httpx
|
|
from bolt11 import (
|
|
decode,
|
|
)
|
|
from loguru import logger
|
|
|
|
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,
|
|
)
|
|
|
|
# according to https://github.com/GaloyMoney/galoy/blob/7e79cc27304de9b9c2e7d7f4fdd3bac09df23aac/core/api/src/domain/bitcoin/index.ts#L59
|
|
BLINK_MAX_FEE_PERCENT = 0.5
|
|
# according to https://github.com/GaloyMoney/blink/blob/7e79cc27304de9b9c2e7d7f4fdd3bac09df23aac/core/api/src/domain/bitcoin/index.ts#L60C1-L60C41
|
|
MINIMUM_FEE_MSAT = 10_000
|
|
|
|
DIRECTION_SEND = "SEND"
|
|
DIRECTION_RECEIVE = "RECEIVE"
|
|
PROBE_FEE_TIMEOUT_SEC = 1
|
|
|
|
|
|
INVOICE_RESULT_MAP = {
|
|
"PENDING": PaymentResult.PENDING,
|
|
"PAID": PaymentResult.SETTLED,
|
|
"EXPIRED": PaymentResult.FAILED,
|
|
}
|
|
PAYMENT_EXECUTION_RESULT_MAP = {
|
|
"SUCCESS": PaymentResult.SETTLED,
|
|
"ALREADY_PAID": PaymentResult.FAILED,
|
|
"FAILURE": PaymentResult.FAILED,
|
|
}
|
|
PAYMENT_RESULT_MAP = {
|
|
"SUCCESS": PaymentResult.SETTLED,
|
|
"PENDING": PaymentResult.PENDING,
|
|
"FAILURE": PaymentResult.FAILED,
|
|
}
|
|
|
|
|
|
class BlinkWallet(LightningBackend):
|
|
"""https://dev.blink.sv/
|
|
Create API Key at: https://dashboard.blink.sv/
|
|
"""
|
|
|
|
wallet_ids: Dict[Unit, str] = {}
|
|
endpoint = "https://api.blink.sv/graphql"
|
|
|
|
supported_units = {Unit.sat, Unit.msat}
|
|
supports_description: bool = True
|
|
unit = Unit.sat
|
|
|
|
def __init__(self, unit: Unit = Unit.sat, **kwargs):
|
|
self.assert_unit_supported(unit)
|
|
self.unit = unit
|
|
assert settings.mint_blink_key, "MINT_BLINK_KEY not set"
|
|
self.client = httpx.AsyncClient(
|
|
verify=not settings.debug,
|
|
headers={
|
|
"X-Api-Key": settings.mint_blink_key,
|
|
"Content-Type": "application/json",
|
|
},
|
|
base_url=self.endpoint,
|
|
timeout=15,
|
|
)
|
|
|
|
async def status(self) -> StatusResponse:
|
|
try:
|
|
data = {
|
|
"query": "query me { me { defaultAccount { wallets { id walletCurrency balance }}}}",
|
|
"variables": {},
|
|
}
|
|
r = await self.client.post(
|
|
url=self.endpoint,
|
|
data=json.dumps(data), # type: ignore
|
|
)
|
|
r.raise_for_status()
|
|
except Exception as exc:
|
|
logger.error(f"Blink API error: {exc}")
|
|
return StatusResponse(
|
|
error_message=f"Failed to connect to {self.endpoint} due to: {exc}",
|
|
balance=Amount(self.unit, 0),
|
|
)
|
|
|
|
try:
|
|
resp: dict = r.json()
|
|
except Exception:
|
|
return StatusResponse(
|
|
error_message=(
|
|
f"Received invalid response from {self.endpoint}: {r.text}"
|
|
),
|
|
balance=Amount(self.unit, 0),
|
|
)
|
|
|
|
balance = 0
|
|
for wallet_dict in (
|
|
resp.get("data", {}).get("me", {}).get("defaultAccount", {}).get("wallets")
|
|
):
|
|
if wallet_dict.get("walletCurrency") == "USD":
|
|
self.wallet_ids[Unit.usd] = wallet_dict["id"] # type: ignore
|
|
elif wallet_dict.get("walletCurrency") == "BTC":
|
|
self.wallet_ids[Unit.sat] = wallet_dict["id"] # type: ignore
|
|
balance = wallet_dict["balance"] # type: ignore
|
|
|
|
return StatusResponse(error_message=None, balance=Amount(self.unit, balance))
|
|
|
|
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)
|
|
|
|
variables = {
|
|
"input": {
|
|
"amount": str(amount.to(Unit.sat).amount),
|
|
"recipientWalletId": self.wallet_ids[Unit.sat],
|
|
}
|
|
}
|
|
if description_hash:
|
|
variables["input"]["descriptionHash"] = description_hash.hex()
|
|
if memo:
|
|
variables["input"]["memo"] = memo
|
|
|
|
data = {
|
|
"query": """
|
|
mutation LnInvoiceCreateOnBehalfOfRecipient($input: LnInvoiceCreateOnBehalfOfRecipientInput!) {
|
|
lnInvoiceCreateOnBehalfOfRecipient(input: $input) {
|
|
invoice {
|
|
paymentRequest
|
|
paymentHash
|
|
paymentSecret
|
|
satoshis
|
|
}
|
|
errors {
|
|
message path code
|
|
}
|
|
}
|
|
}
|
|
""",
|
|
"variables": variables,
|
|
}
|
|
try:
|
|
r = await self.client.post(
|
|
url=self.endpoint,
|
|
data=json.dumps(data), # type: ignore
|
|
)
|
|
r.raise_for_status()
|
|
except Exception as e:
|
|
logger.error(f"Blink API error: {e}")
|
|
return InvoiceResponse(ok=False, error_message=str(e))
|
|
|
|
resp = r.json()
|
|
assert resp, "invalid response"
|
|
payment_request = (
|
|
resp.get("data", {})
|
|
.get("lnInvoiceCreateOnBehalfOfRecipient", {})
|
|
.get("invoice", {})
|
|
.get("paymentRequest")
|
|
)
|
|
assert payment_request, "payment request not found"
|
|
checking_id = payment_request
|
|
|
|
return InvoiceResponse(
|
|
ok=True,
|
|
checking_id=checking_id,
|
|
payment_request=payment_request,
|
|
)
|
|
|
|
async def pay_invoice(
|
|
self, quote: MeltQuote, fee_limit_msat: int
|
|
) -> PaymentResponse:
|
|
variables = {
|
|
"input": {
|
|
"paymentRequest": quote.request,
|
|
"walletId": self.wallet_ids[Unit.sat],
|
|
}
|
|
}
|
|
data = {
|
|
"query": """
|
|
mutation lnInvoicePaymentSend($input: LnInvoicePaymentInput!) {
|
|
lnInvoicePaymentSend(input: $input) {
|
|
errors {
|
|
message path code
|
|
}
|
|
status
|
|
transaction {
|
|
settlementAmount settlementFee status
|
|
}
|
|
}
|
|
}
|
|
""",
|
|
"variables": variables,
|
|
}
|
|
|
|
try:
|
|
r = await self.client.post(
|
|
url=self.endpoint,
|
|
data=json.dumps(data), # type: ignore
|
|
timeout=None,
|
|
)
|
|
r.raise_for_status()
|
|
except Exception as e:
|
|
logger.error(f"Blink API error: {e}")
|
|
return PaymentResponse(
|
|
result=PaymentResult.UNKNOWN,
|
|
error_message=str(e),
|
|
)
|
|
|
|
resp: dict = r.json()
|
|
|
|
error_message: Union[None, str] = None
|
|
fee: Union[None, int] = None
|
|
if resp.get("data", {}).get("lnInvoicePaymentSend", {}).get("errors"):
|
|
error_message = (
|
|
resp["data"]["lnInvoicePaymentSend"]["errors"][0].get("message") # type: ignore
|
|
or "Unknown error"
|
|
)
|
|
|
|
status_str = resp.get("data", {}).get("lnInvoicePaymentSend", {}).get("status")
|
|
result = PAYMENT_EXECUTION_RESULT_MAP[status_str]
|
|
|
|
if status_str == "ALREADY_PAID":
|
|
error_message = "Invoice already paid"
|
|
|
|
if result == PaymentResult.FAILED:
|
|
return PaymentResponse(
|
|
result=result,
|
|
error_message=error_message,
|
|
checking_id=quote.request,
|
|
)
|
|
|
|
if resp.get("data", {}).get("lnInvoicePaymentSend", {}).get("transaction", {}):
|
|
fee = (
|
|
resp.get("data", {})
|
|
.get("lnInvoicePaymentSend", {})
|
|
.get("transaction", {})
|
|
.get("settlementFee")
|
|
)
|
|
|
|
checking_id = quote.request
|
|
# we check the payment status to get the preimage
|
|
preimage: Union[None, str] = None
|
|
payment_status = await self.get_payment_status(checking_id)
|
|
if payment_status.settled:
|
|
preimage = payment_status.preimage
|
|
|
|
return PaymentResponse(
|
|
result=result,
|
|
checking_id=checking_id,
|
|
fee=Amount(Unit.sat, fee) if fee else None,
|
|
preimage=preimage,
|
|
error_message=error_message,
|
|
)
|
|
|
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
|
variables = {"input": {"paymentRequest": checking_id}}
|
|
data = {
|
|
"query": """
|
|
query lnInvoicePaymentStatus($input: LnInvoicePaymentStatusInput!) {
|
|
lnInvoicePaymentStatus(input: $input) {
|
|
errors {
|
|
message path code
|
|
}
|
|
status
|
|
}
|
|
}
|
|
""",
|
|
"variables": variables,
|
|
}
|
|
try:
|
|
r = await self.client.post(url=self.endpoint, data=json.dumps(data)) # type: ignore
|
|
r.raise_for_status()
|
|
except Exception as e:
|
|
logger.error(f"Blink API error: {e}")
|
|
return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e))
|
|
resp: dict = r.json()
|
|
error_message = (
|
|
resp.get("data", {}).get("lnInvoicePaymentStatus", {}).get("errors")
|
|
)
|
|
if error_message:
|
|
logger.error(
|
|
"Blink Error",
|
|
error_message,
|
|
)
|
|
return PaymentStatus(
|
|
result=PaymentResult.UNKNOWN, error_message=error_message
|
|
)
|
|
result = INVOICE_RESULT_MAP[
|
|
resp.get("data", {}).get("lnInvoicePaymentStatus", {}).get("status")
|
|
]
|
|
return PaymentStatus(result=result)
|
|
|
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
|
# Checking ID is the payment request and blink wants the payment hash
|
|
payment_hash = bolt11.decode(checking_id).payment_hash
|
|
variables = {
|
|
"paymentHash": payment_hash,
|
|
"walletId": self.wallet_ids[Unit.sat],
|
|
}
|
|
data = {
|
|
"query": """
|
|
query TransactionsByPaymentHash($paymentHash: PaymentHash!, $walletId: WalletId!) {
|
|
me {
|
|
defaultAccount {
|
|
walletById(walletId: $walletId) {
|
|
transactionsByPaymentHash(paymentHash: $paymentHash) {
|
|
status
|
|
direction
|
|
settlementFee
|
|
settlementVia {
|
|
... on SettlementViaIntraLedger {
|
|
preImage
|
|
}
|
|
... on SettlementViaLn {
|
|
preImage
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""",
|
|
"variables": variables,
|
|
}
|
|
r = await self.client.post(
|
|
url=self.endpoint,
|
|
data=json.dumps(data), # type: ignore
|
|
)
|
|
r.raise_for_status()
|
|
|
|
resp: dict = r.json()
|
|
|
|
# no result found, this payment has not been attempted before
|
|
if (
|
|
not resp.get("data", {})
|
|
.get("me", {})
|
|
.get("defaultAccount", {})
|
|
.get("walletById", {})
|
|
.get("transactionsByPaymentHash")
|
|
):
|
|
return PaymentStatus(
|
|
result=PaymentResult.UNKNOWN, error_message="No payment found"
|
|
)
|
|
|
|
all_payments_with_this_hash = (
|
|
resp.get("data", {})
|
|
.get("me", {})
|
|
.get("defaultAccount", {})
|
|
.get("walletById", {})
|
|
.get("transactionsByPaymentHash")
|
|
)
|
|
|
|
# Blink API edge case: for a previously failed payment attempt, it returns the two payments with the same hash
|
|
# if there are two payments with the same hash with "direction" == "SEND" and "RECEIVE"
|
|
# it means that the payment previously failed and we can ignore the attempt and return
|
|
# PaymentStatus(status=FAILED)
|
|
if len(all_payments_with_this_hash) == 2 and all(
|
|
p["direction"] in [DIRECTION_SEND, DIRECTION_RECEIVE] # type: ignore
|
|
for p in all_payments_with_this_hash
|
|
):
|
|
return PaymentStatus(
|
|
result=PaymentResult.FAILED, error_message="Payment failed"
|
|
)
|
|
|
|
# if there is only one payment with the same hash, it means that the payment might have succeeded
|
|
# we only care about the payment with "direction" == "SEND"
|
|
payment = next(
|
|
(
|
|
p
|
|
for p in all_payments_with_this_hash
|
|
if p.get("direction") == DIRECTION_SEND
|
|
),
|
|
None,
|
|
)
|
|
if not payment:
|
|
return PaymentStatus(
|
|
result=PaymentResult.UNKNOWN, error_message="No payment found"
|
|
)
|
|
|
|
# we read the status of the payment
|
|
result = PAYMENT_RESULT_MAP[payment["status"]] # type: ignore
|
|
fee = payment["settlementFee"] # type: ignore
|
|
preimage = payment["settlementVia"].get("preImage") # type: ignore
|
|
|
|
return PaymentStatus(
|
|
result=result,
|
|
fee=Amount(Unit.sat, fee),
|
|
preimage=preimage,
|
|
)
|
|
|
|
async def get_payment_quote(
|
|
self, melt_quote: PostMeltQuoteRequest
|
|
) -> PaymentQuoteResponse:
|
|
bolt11 = melt_quote.request
|
|
variables = {
|
|
"input": {
|
|
"paymentRequest": bolt11,
|
|
"walletId": self.wallet_ids[Unit.sat],
|
|
}
|
|
}
|
|
data = {
|
|
"query": """
|
|
mutation lnInvoiceFeeProbe($input: LnInvoiceFeeProbeInput!) {
|
|
lnInvoiceFeeProbe(input: $input) {
|
|
amount
|
|
errors {
|
|
message path code
|
|
}
|
|
}
|
|
}
|
|
""",
|
|
"variables": variables,
|
|
}
|
|
|
|
fees_response_msat = 0
|
|
try:
|
|
r = await self.client.post(
|
|
url=self.endpoint,
|
|
data=json.dumps(data), # type: ignore
|
|
timeout=PROBE_FEE_TIMEOUT_SEC,
|
|
)
|
|
r.raise_for_status()
|
|
resp: dict = r.json()
|
|
if resp.get("data", {}).get("lnInvoiceFeeProbe", {}).get("errors"):
|
|
# if there was an error, we simply ignore the response and decide the fees ourselves
|
|
fees_response_msat = 0
|
|
logger.debug(
|
|
f"Blink probe error: {resp['data']['lnInvoiceFeeProbe']['errors'][0].get('message')}" # type: ignore
|
|
)
|
|
|
|
else:
|
|
fees_response_msat = (
|
|
int(resp.get("data", {}).get("lnInvoiceFeeProbe", {}).get("amount"))
|
|
* 1000
|
|
)
|
|
except httpx.ReadTimeout:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Blink API error: {e}")
|
|
raise e
|
|
|
|
invoice_obj = decode(bolt11)
|
|
assert invoice_obj.amount_msat, "invoice has no amount."
|
|
|
|
amount_msat = int(invoice_obj.amount_msat)
|
|
|
|
# we take the highest: fee_msat_response, or BLINK_MAX_FEE_PERCENT, or MINIMUM_FEE_MSAT msat
|
|
# Note: fees with BLINK_MAX_FEE_PERCENT are rounded to the nearest 1000 msat
|
|
fees_amount_msat: int = (
|
|
math.ceil(amount_msat / 100 * BLINK_MAX_FEE_PERCENT / 1000) * 1000
|
|
)
|
|
|
|
fees_msat: int = max(
|
|
fees_response_msat,
|
|
max(
|
|
fees_amount_msat,
|
|
MINIMUM_FEE_MSAT,
|
|
),
|
|
)
|
|
|
|
fees = Amount(unit=Unit.msat, amount=fees_msat)
|
|
amount = Amount(unit=Unit.msat, amount=amount_msat)
|
|
return PaymentQuoteResponse(
|
|
checking_id=bolt11,
|
|
fee=fees.to(self.unit, round="up"),
|
|
amount=amount.to(self.unit, round="up"),
|
|
)
|
|
|
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: # type: ignore
|
|
raise NotImplementedError("paid_invoices_stream not implemented")
|