Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

USD-denominated Strike backend #334

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cashu/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ class MintSettings(CashuSettings):
mint_lnbits_endpoint: str = Field(default=None)
mint_lnbits_key: str = Field(default=None)

mint_strike_key: str = Field(default=None)


class MintInformation(CashuSettings):
mint_info_name: str = Field(default="Cashu mint")
Expand Down
1 change: 1 addition & 0 deletions cashu/lightning/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# type: ignore
from .fake import FakeWallet # noqa: F401
from .lnbits import LNbitsWallet # noqa: F401
from .strike import StrikeUSDWallet # noqa: F401
9 changes: 9 additions & 0 deletions cashu/lightning/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ class InvoiceResponse(NamedTuple):
error_message: Optional[str] = None


class PaymentQuote(NamedTuple):
id: str
amount: int


class PaymentResponse(NamedTuple):
# when ok is None it means we don't know if this succeeded
ok: Optional[bool] = None
Expand Down Expand Up @@ -61,6 +66,10 @@ def create_invoice(
) -> Coroutine[None, None, InvoiceResponse]:
pass

@abstractmethod
def get_invoice_quote(self, bolt11: str) -> Coroutine[None, None, PaymentQuote]:
pass

@abstractmethod
def pay_invoice(
self, bolt11: str, fee_limit_msat: int
Expand Down
185 changes: 185 additions & 0 deletions cashu/lightning/strike.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# type: ignore
import secrets
from typing import Dict, Optional

import httpx

from ..core.settings import settings
from .base import (
InvoiceResponse,
PaymentQuote,
PaymentResponse,
PaymentStatus,
StatusResponse,
Wallet,
)


class StrikeUSDWallet(Wallet):
"""https://github.com/lnbits/lnbits"""

def __init__(self):
self.endpoint = "https://api.strike.me"

# 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,
)

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(
f"Failed to connect to {self.endpoint} due to: {exc}", 0
)

try:
data = r.json()
except Exception:
return StatusResponse(
f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0
)

for balance in data:
if balance["currency"] == "USD":
return StatusResponse(None, balance["total"])

async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse:
data: Dict = {"out": False, "amount": amount}
if description_hash:
data["description_hash"] = description_hash.hex()
if unhashed_description:
data["unhashed_description"] = unhashed_description.hex()

data["memo"] = memo or ""
payload = {
"correlationId": secrets.token_hex(16),
"description": "Invoice for order 123",
"amount": {"amount": str(amount / 100), "currency": "USD"},
}
try:
r = await self.client.post(url=f"{self.endpoint}/v1/invoices", json=payload)
r.raise_for_status()
except Exception:
return InvoiceResponse(False, None, None, r.json()["detail"])
ok, checking_id, payment_request, error_message = (
True,
None,
None,
None,
)
quote = r.json()
invoice_id = quote.get("invoiceId")

try:
payload = {"descriptionHash": secrets.token_hex(32)}
r2 = await self.client.post(
f"{self.endpoint}/v1/invoices/{invoice_id}/quote", json=payload
)
except Exception:
return InvoiceResponse(False, None, None, r.json()["detail"])
ok, checking_id, payment_request, error_message = (
True,
None,
None,
None,
)

data2 = r2.json()
payment_request = data2.get("lnInvoice")
assert payment_request, "Did not receive an invoice"
checking_id = invoice_id
return InvoiceResponse(ok, checking_id, payment_request, error_message)

async def get_invoice_quote(self, bolt11: str) -> PaymentQuote:
try:
r = await self.client.post(
url=f"{self.endpoint}/v1/payment-quotes/lightning",
json={"sourceCurrency": "USD", "lnInvoice": bolt11},
timeout=None,
)
r.raise_for_status()
except Exception:
error_message = r.json()["data"]["message"]
raise Exception(error_message)
data = r.json()

amount_cent = int(float(data.get("amount").get("amount")) * 100)
quote = PaymentQuote(amount=amount_cent, id=data.get("paymentQuoteId"))
return quote

async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
try:
r = await self.client.patch(
url=f"{self.endpoint}/v1/payment-quotes/{bolt11}/execute",
timeout=None,
)
r.raise_for_status()
except Exception:
error_message = r.json()["data"]["message"]
return PaymentResponse(None, None, None, None, error_message)

data = r.json()
states = {"PENDING": None, "COMPLETED": True, "FAILED": False}
if states[data.get("state")]:
return PaymentResponse(True, "", 0, "")
else:
return PaymentResponse(False, "", 0, "")

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:
return PaymentStatus(None)
data = r.json()
states = {"PENDING": None, "UNPAID": None, "PAID": True, "CANCELLED": False}
return PaymentStatus(states[data["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()
except Exception:
return PaymentStatus(None)
data = r.json()
if "paid" not in data and "details" not in data:
return PaymentStatus(None)

return PaymentStatus(data["paid"], data["details"]["fee"], data["preimage"])

# async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
# url = f"{self.endpoint}/api/v1/payments/sse"

# while True:
# try:
# async with requests.stream("GET", url) as r:
# async for line in r.aiter_lines():
# if line.startswith("data:"):
# try:
# data = json.loads(line[5:])
# except json.decoder.JSONDecodeError:
# continue

# if type(data) is not dict:
# continue

# yield data["payment_hash"] # payment_hash

# except:
# pass

# print("lost connection to lnbits /payments/sse, retrying in 5 seconds")
# await asyncio.sleep(5)
45 changes: 33 additions & 12 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from ..core.helpers import fee_reserve, sum_proofs
from ..core.settings import settings
from ..core.split import amount_split
from ..lightning.base import Wallet
from ..lightning.base import PaymentQuote, Wallet
from ..mint.crud import LedgerCrud
from .conditions import LedgerSpendingConditions
from .verification import LedgerVerification
Expand Down Expand Up @@ -420,6 +420,26 @@ async def mint(
logger.trace("generated promises")
return promises

async def getmelt(self, bolt11: str) -> PaymentQuote:
"""_summary_

Args:
bolt11 (str): Lightning invoice to get the amount of

Returns:
int: Amount to pay
"""
quote = await self.lightning.get_invoice_quote(bolt11)
if "error_message" in quote:
raise LightningError(quote["error_message"])
invoice = Invoice(
amount=quote.amount,
hash=quote.id,
pr=bolt11,
)
await self.crud.store_lightning_invoice(invoice=invoice, db=self.db)
return quote

async def melt(
self, proofs: List[Proof], invoice: str, outputs: Optional[List[BlindedMessage]]
) -> Tuple[bool, str, List[BlindedSignature]]:
Expand All @@ -444,13 +464,14 @@ async def melt(
try:
# verify amounts
total_provided = sum_proofs(proofs)
invoice_obj = bolt11.decode(invoice)
invoice_amount = math.ceil(invoice_obj.amount_msat / 1000)
invoiceObj = await self.crud.get_lightning_invoice(hash=invoice, db=self.db)
assert invoiceObj, "Invoice not found"
invoice_amount = invoiceObj.amount
if settings.mint_max_peg_out and invoice_amount > settings.mint_max_peg_out:
raise NotAllowedError(
f"Maximum melt amount is {settings.mint_max_peg_out} sat."
)
fees_sat = await self.get_melt_fees(invoice)
fees_sat = 0 # await self.get_melt_fees(invoice)
# verify overspending attempt
assert total_provided >= invoice_amount + fees_sat, TransactionError(
"provided proofs not enough for Lightning payment. Provided:"
Expand All @@ -463,7 +484,7 @@ async def melt(
if settings.lightning:
logger.trace(f"paying lightning invoice {invoice}")
status, preimage, fee_msat = await self._pay_lightning_invoice(
invoice, fees_sat * 1000
invoice, 0
)
preimage = preimage or ""
logger.trace("paid lightning invoice")
Expand All @@ -482,13 +503,13 @@ async def melt(

# prepare change to compensate wallet for overpaid fees
return_promises: List[BlindedSignature] = []
if outputs and fee_msat:
return_promises = await self._generate_change_promises(
total_provided=total_provided,
invoice_amount=invoice_amount,
ln_fee_msat=fee_msat,
outputs=outputs,
)
# if outputs and fee_msat:
# return_promises = await self._generate_change_promises(
# total_provided=total_provided,
# invoice_amount=invoice_amount,
# ln_fee_msat=fee_msat,
# outputs=outputs,
# )

except Exception as e:
logger.trace(f"exception: {e}")
Expand Down
7 changes: 7 additions & 0 deletions cashu/mint/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
)
from ..core.errors import CashuError
from ..core.settings import settings
from ..lightning.base import PaymentQuote
from ..mint.startup import ledger

router: APIRouter = APIRouter()
Expand Down Expand Up @@ -171,6 +172,12 @@ async def mint(
return blinded_signatures


@router.get("/melt", name="Request melt", summary="Request melting of tokens")
async def getmelt(invoice: str) -> PaymentQuote:
quote = await ledger.getmelt(invoice)
return quote


@router.post(
"/melt",
name="Melt tokens",
Expand Down
6 changes: 3 additions & 3 deletions cashu/mint/startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ async def start_mint_init():
error_message, balance = await ledger.lightning.status()
if error_message:
logger.warning(
f"The backend for {ledger.lightning.__class__.__name__} isn't working"
f" properly: '{error_message}'",
f"The backend for {ledger.lightning.__class__.__name__} isn't"
f" working properly: '{error_message}'",
RuntimeWarning,
)
logger.info(f"Lightning balance: {balance} msat")
logger.info(f"Backend balance: {balance} cent")

logger.info(f"Data dir: {settings.cashu_dir}")
logger.info("Mint started.")
Expand Down
Loading
Loading