From cf849b260cd97e3dbe1d2e11244d67c13962306b Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 16 Sep 2022 13:20:42 +0100 Subject: [PATCH] UI works well --- lnbits/extensions/cashu/README.md | 11 + lnbits/extensions/cashu/__init__.py | 25 + lnbits/extensions/cashu/config.json | 6 + lnbits/extensions/cashu/crud.py | 50 ++ lnbits/extensions/cashu/migrations.py | 33 + lnbits/extensions/cashu/models.py | 34 + lnbits/extensions/cashu/tasks.py | 70 ++ .../cashu/templates/cashu/_api_docs.html | 79 ++ .../cashu/templates/cashu/_cashu.html | 15 + .../cashu/templates/cashu/index.html | 262 ++++++ .../cashu/templates/cashu/mint.html | 33 + .../cashu/templates/cashu/wallet.html | 753 ++++++++++++++++++ lnbits/extensions/cashu/views.py | 69 ++ lnbits/extensions/cashu/views_api.py | 160 ++++ 14 files changed, 1600 insertions(+) create mode 100644 lnbits/extensions/cashu/README.md create mode 100644 lnbits/extensions/cashu/__init__.py create mode 100644 lnbits/extensions/cashu/config.json create mode 100644 lnbits/extensions/cashu/crud.py create mode 100644 lnbits/extensions/cashu/migrations.py create mode 100644 lnbits/extensions/cashu/models.py create mode 100644 lnbits/extensions/cashu/tasks.py create mode 100644 lnbits/extensions/cashu/templates/cashu/_api_docs.html create mode 100644 lnbits/extensions/cashu/templates/cashu/_cashu.html create mode 100644 lnbits/extensions/cashu/templates/cashu/index.html create mode 100644 lnbits/extensions/cashu/templates/cashu/mint.html create mode 100644 lnbits/extensions/cashu/templates/cashu/wallet.html create mode 100644 lnbits/extensions/cashu/views.py create mode 100644 lnbits/extensions/cashu/views_api.py diff --git a/lnbits/extensions/cashu/README.md b/lnbits/extensions/cashu/README.md new file mode 100644 index 000000000..8f53b474b --- /dev/null +++ b/lnbits/extensions/cashu/README.md @@ -0,0 +1,11 @@ +# Cashu + +## Create ecash mint for pegging in/out of ecash + + + +### Usage + +1. Enable extension +2. Create a Mint +3. Share wallet diff --git a/lnbits/extensions/cashu/__init__.py b/lnbits/extensions/cashu/__init__.py new file mode 100644 index 000000000..fa549ad2e --- /dev/null +++ b/lnbits/extensions/cashu/__init__.py @@ -0,0 +1,25 @@ +import asyncio + +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_cashu") + +cashu_ext: APIRouter = APIRouter(prefix="/cashu", tags=["TPoS"]) + + +def cashu_renderer(): + return template_renderer(["lnbits/extensions/cashu/templates"]) + + +from .tasks import wait_for_paid_invoices +from .views import * # noqa +from .views_api import * # noqa + + +def cashu_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/cashu/config.json b/lnbits/extensions/cashu/config.json new file mode 100644 index 000000000..c688b22c2 --- /dev/null +++ b/lnbits/extensions/cashu/config.json @@ -0,0 +1,6 @@ +{ + "name": "Cashu Ecash", + "short_description": "Ecash mints with LN peg in/out", + "icon": "approval", + "contributors": ["shinobi", "arcbtc", "calle"] +} diff --git a/lnbits/extensions/cashu/crud.py b/lnbits/extensions/cashu/crud.py new file mode 100644 index 000000000..ce83653fc --- /dev/null +++ b/lnbits/extensions/cashu/crud.py @@ -0,0 +1,50 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import Cashu, Pegs + + +async def create_cashu(wallet_id: str, data: Cashu) -> Cashu: + cashu_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO cashu.cashu (id, wallet, name, tickershort, fraction, maxsats, coins) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + cashu_id, + wallet_id, + data.name, + data.tickershort, + data.fraction, + data.maxsats, + data.coins + ), + ) + + cashu = await get_cashu(cashu_id) + assert cashu, "Newly created cashu couldn't be retrieved" + return cashu + + +async def get_cashu(cashu_id: str) -> Optional[Cashu]: + row = await db.fetchone("SELECT * FROM cashu.cashu WHERE id = ?", (cashu_id,)) + return Cashu(**row) if row else None + + +async def get_cashus(wallet_ids: Union[str, List[str]]) -> List[Cashu]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM cashu.cashu WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Cashu(**row) for row in rows] + + +async def delete_cashu(cashu_id: str) -> None: + await db.execute("DELETE FROM cashu.cashu WHERE id = ?", (cashu_id,)) diff --git a/lnbits/extensions/cashu/migrations.py b/lnbits/extensions/cashu/migrations.py new file mode 100644 index 000000000..95dc48152 --- /dev/null +++ b/lnbits/extensions/cashu/migrations.py @@ -0,0 +1,33 @@ +async def m001_initial(db): + """ + Initial cashu table. + """ + await db.execute( + """ + CREATE TABLE cashu.cashu ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + tickershort TEXT NOT NULL, + fraction BOOL, + maxsats INT, + coins INT + + ); + """ + ) + + """ + Initial cashus table. + """ + await db.execute( + """ + CREATE TABLE cashu.pegs ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + inout BOOL NOT NULL, + amount INT + ); + """ + ) + diff --git a/lnbits/extensions/cashu/models.py b/lnbits/extensions/cashu/models.py new file mode 100644 index 000000000..0de153622 --- /dev/null +++ b/lnbits/extensions/cashu/models.py @@ -0,0 +1,34 @@ +from sqlite3 import Row +from typing import Optional + +from fastapi import Query +from pydantic import BaseModel + + +class Cashu(BaseModel): + id: str = Query(None) + name: str = Query(None) + wallet: str = Query(None) + tickershort: str + fraction: bool = Query(None) + maxsats: int = Query(0) + coins: int = Query(0) + + + @classmethod + def from_row(cls, row: Row) -> "TPoS": + return cls(**dict(row)) + +class Pegs(BaseModel): + id: str + wallet: str + inout: str + amount: str + + + @classmethod + def from_row(cls, row: Row) -> "TPoS": + return cls(**dict(row)) + +class PayLnurlWData(BaseModel): + lnurl: str \ No newline at end of file diff --git a/lnbits/extensions/cashu/tasks.py b/lnbits/extensions/cashu/tasks.py new file mode 100644 index 000000000..fe00a5918 --- /dev/null +++ b/lnbits/extensions/cashu/tasks.py @@ -0,0 +1,70 @@ +import asyncio +import json + +from lnbits.core import db as core_db +from lnbits.core.crud import create_payment +from lnbits.core.models import Payment +from lnbits.helpers import urlsafe_short_hash +from lnbits.tasks import internal_invoice_queue, register_invoice_listener + +from .crud import get_cashu + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + if payment.extra.get("tag") == "cashu" and payment.extra.get("tipSplitted"): + # already splitted, ignore + return + + # now we make some special internal transfers (from no one to the receiver) + cashu = await get_cashu(payment.extra.get("cashuId")) + tipAmount = payment.extra.get("tipAmount") + + if tipAmount is None: + # no tip amount + return + + tipAmount = tipAmount * 1000 + amount = payment.amount - tipAmount + + # mark the original payment with one extra key, "splitted" + # (this prevents us from doing this process again and it's informative) + # and reduce it by the amount we're going to send to the producer + await core_db.execute( + """ + UPDATE apipayments + SET extra = ?, amount = ? + WHERE hash = ? + AND checking_id NOT LIKE 'internal_%' + """, + ( + json.dumps(dict(**payment.extra, tipSplitted=True)), + amount, + payment.payment_hash, + ), + ) + + # perform the internal transfer using the same payment_hash + internal_checking_id = f"internal_{urlsafe_short_hash()}" + await create_payment( + wallet_id=cashu.tip_wallet, + checking_id=internal_checking_id, + payment_request="", + payment_hash=payment.payment_hash, + amount=tipAmount, + memo=f"Tip for {payment.memo}", + pending=False, + extra={"tipSplitted": True}, + ) + + # manually send this for now + await internal_invoice_queue.put(internal_checking_id) + return diff --git a/lnbits/extensions/cashu/templates/cashu/_api_docs.html b/lnbits/extensions/cashu/templates/cashu/_api_docs.html new file mode 100644 index 000000000..7378eb084 --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/_api_docs.html @@ -0,0 +1,79 @@ + + + + + + GET /cashu/api/v1/cashus +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<cashu_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}cashu/api/v1/cashus -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + POST /cashu/api/v1/cashus +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+ {"name": <string>, "currency": <string*ie USD*>} +
+ Returns 201 CREATED (application/json) +
+ {"currency": <string>, "id": <string>, "name": + <string>, "wallet": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}cashu/api/v1/cashus -d '{"name": + <string>, "currency": <string>}' -H "Content-type: + application/json" -H "X-Api-Key: <admin_key>" + +
+
+
+ + + + + DELETE + /cashu/api/v1/cashus/<cashu_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.base_url + }}cashu/api/v1/cashus/<cashu_id> -H "X-Api-Key: <admin_key>" + +
+
+
+
diff --git a/lnbits/extensions/cashu/templates/cashu/_cashu.html b/lnbits/extensions/cashu/templates/cashu/_cashu.html new file mode 100644 index 000000000..3c2a38f53 --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/_cashu.html @@ -0,0 +1,15 @@ + + + +

+ Make Ecash mints with peg in/out to a wallet, that can create and manage ecash. +

+ Created by + Calle. +
+
+
diff --git a/lnbits/extensions/cashu/templates/cashu/index.html b/lnbits/extensions/cashu/templates/cashu/index.html new file mode 100644 index 000000000..17b2a9194 --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/index.html @@ -0,0 +1,262 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Mint + + + + + +
+
+
Mints
+
+
+ Export to CSV +
+
+ + {% raw %} + + + + {% endraw %} + +
+
+
+ +
+ + +
{{SITE_TITLE}} Cashu extension
+
+ + + + {% include "cashu/_api_docs.html" %} + + {% include "cashu/_cashu.html" %} + + +
+
+ + + + + + + + +
+ +
+
+ + Use with hedging extension to create a stablecoin! + +
+
+ +
+
+ + +
+
+ Create Mint + + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} \ No newline at end of file diff --git a/lnbits/extensions/cashu/templates/cashu/mint.html b/lnbits/extensions/cashu/templates/cashu/mint.html new file mode 100644 index 000000000..0f3e0e09a --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/mint.html @@ -0,0 +1,33 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+ +

{{ mint_name }}

+
+
+
Some data about mint here:
* whether its online
* Who to contact for support
* etc...
+
+
+
+ + {% endblock %} {% block scripts %} + + + + {% endblock %} +
diff --git a/lnbits/extensions/cashu/templates/cashu/wallet.html b/lnbits/extensions/cashu/templates/cashu/wallet.html new file mode 100644 index 000000000..a5d5f371e --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/wallet.html @@ -0,0 +1,753 @@ +{% extends "public.html" %} {% block toolbar_title %} {% raw %} {{name}} Wallet {% endraw %} + +{% endblock %} {% block footer %}{% endblock %} {% block page_container %} + + +
+ + +
+ + +

+
{% raw %} {{balanceAmount}} + {{tickershort}}{% endraw %}
+

+
+ +
+ + +
+
+ Receive +
+
+ Send +
+
+ Peg in/out + +
+
+ scan + +
+ +
+ + + + +
+
+
Transactions
+
+
{% raw %} + {% endraw %} + Mint details + + Export to CSV + + + Show chart + +
+
+ + + + {% raw %} + + + {% endraw %} + +
+
+
+ + + {% raw %} + + +

+ {{receive.lnurl.domain}} is requesting an invoice: +

+ {% endraw %} {% if LNBITS_DENOMINATION != 'sats' %} + + {% else %} + + + {% endif %} + + + {% raw %} +
+ + + Withdraw from {{receive.lnurl.domain}} + + Create invoice + + Cancel +
+ +
+
+ + +
+ Copy invoice + Close +
+
+ {% endraw %} +
+ + + +
+
+ {% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",", + "")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %} +
+
+ {{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% + raw %} +
+ +

+ Description: {{ parse.invoice.description }}
+ Expire date: {{ parse.invoice.expireDate }}
+ Hash: {{ parse.invoice.hash }} +

+ {% endraw %} +
+ Pay + Cancel +
+
+ Not enough funds! + Cancel +
+
+
+ {% raw %} + +

+ Authenticate with {{ parse.lnurlauth.domain }}? +

+ +

+ For every website and for every LNbits wallet, a new keypair + will be deterministically generated so your identity can't be + tied to your LNbits wallet or linked across websites. No other + data will be shared with {{ parse.lnurlauth.domain }}. +

+

Your public key for {{ parse.lnurlauth.domain }} is:

+

+ {{ parse.lnurlauth.pubkey }} +

+
+ Login + Cancel +
+
+ {% endraw %} +
+
+ + + +
+ Read + Cancel +
+
+
+ + + +
+ + Cancel + +
+
+
+
+
+ + + +
+ +
+
+ Cancel +
+
+
+ + + + + + + + + + + + + + + + + + +
Warning
+

+ BOOKMARK THIS PAGE! If only mobile you can also click the 3 dots + and "Save to homescreen"/"Install app"! +

+

+ Ecash is a bearer asset, meaning you have the funds saved on this + page, losing the page without exporting the page will mean you will + lose the funds. +

+
+ Copy wallet URL + I understand +
+
+
+
+
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/cashu/views.py b/lnbits/extensions/cashu/views.py new file mode 100644 index 000000000..4ac1f1cea --- /dev/null +++ b/lnbits/extensions/cashu/views.py @@ -0,0 +1,69 @@ +from http import HTTPStatus + +from fastapi import Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists +from lnbits.settings import LNBITS_CUSTOM_LOGO, LNBITS_SITE_TITLE + +from . import cashu_ext, cashu_renderer +from .crud import get_cashu + +templates = Jinja2Templates(directory="templates") + + +@cashu_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return cashu_renderer().TemplateResponse( + "cashu/index.html", {"request": request, "user": user.dict()} + ) + + +@cashu_ext.get("/wallet") +async def cashu(request: Request): + return cashu_renderer().TemplateResponse("cashu/wallet.html",{"request": request}) + +@cashu_ext.get("/mint/{mintID}") +async def cashu(request: Request, mintID): + cashu = await get_cashu(mintID) + return cashu_renderer().TemplateResponse("cashu/mint.html",{"request": request, "mint_name": cashu.name}) + +@cashu_ext.get("/manifest/{cashu_id}.webmanifest") +async def manifest(cashu_id: str): + cashu = await get_cashu(cashu_id) + if not cashu: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + + return { + "short_name": LNBITS_SITE_TITLE, + "name": cashu.name + " - " + LNBITS_SITE_TITLE, + "icons": [ + { + "src": LNBITS_CUSTOM_LOGO + if LNBITS_CUSTOM_LOGO + else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png", + "type": "image/png", + "sizes": "900x900", + } + ], + "start_url": "/cashu/" + cashu_id, + "background_color": "#1F2234", + "description": "Bitcoin Lightning tPOS", + "display": "standalone", + "scope": "/cashu/" + cashu_id, + "theme_color": "#1F2234", + "shortcuts": [ + { + "name": cashu.name + " - " + LNBITS_SITE_TITLE, + "short_name": cashu.name, + "description": cashu.name + " - " + LNBITS_SITE_TITLE, + "url": "/cashu/" + cashu_id, + } + ], + } diff --git a/lnbits/extensions/cashu/views_api.py b/lnbits/extensions/cashu/views_api.py new file mode 100644 index 000000000..0b16e4bad --- /dev/null +++ b/lnbits/extensions/cashu/views_api.py @@ -0,0 +1,160 @@ +from http import HTTPStatus + +import httpx +from fastapi import Query +from fastapi.params import Depends +from lnurl import decode as decode_lnurl +from loguru import logger +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_user +from lnbits.core.services import create_invoice +from lnbits.core.views.api import api_payment +from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key + +from . import cashu_ext +from .crud import create_cashu, delete_cashu, get_cashu, get_cashus +from .models import Cashu, Pegs, PayLnurlWData + + +@cashu_ext.get("/api/v1/cashus", status_code=HTTPStatus.OK) +async def api_cashus( + all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type) +): + wallet_ids = [wallet.wallet.id] + if all_wallets: + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + + return [cashu.dict() for cashu in await get_cashus(wallet_ids)] + + +@cashu_ext.post("/api/v1/cashus", status_code=HTTPStatus.CREATED) +async def api_cashu_create( + data: Cashu, wallet: WalletTypeInfo = Depends(get_key_type) +): + cashu = await create_cashu(wallet_id=wallet.wallet.id, data=data) + logger.debug(cashu) + return cashu.dict() + + +@cashu_ext.delete("/api/v1/cashus/{cashu_id}") +async def api_cashu_delete( + cashu_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) +): + cashu = await get_cashu(cashu_id) + + if not cashu: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + + if cashu.wallet != wallet.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.") + + await delete_cashu(cashu_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +@cashu_ext.post("/api/v1/cashus/{cashu_id}/invoices", status_code=HTTPStatus.CREATED) +async def api_cashu_create_invoice( + amount: int = Query(..., ge=1), tipAmount: int = None, cashu_id: str = None +): + cashu = await get_cashu(cashu_id) + + if not cashu: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + + if tipAmount: + amount += tipAmount + + try: + payment_hash, payment_request = await create_invoice( + wallet_id=cashu.wallet, + amount=amount, + memo=f"{cashu.name}", + extra={"tag": "cashu", "tipAmount": tipAmount, "cashuId": cashu_id}, + ) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + return {"payment_hash": payment_hash, "payment_request": payment_request} + + +@cashu_ext.post( + "/api/v1/cashus/{cashu_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK +) +async def api_cashu_pay_invoice( + lnurl_data: PayLnurlWData, payment_request: str = None, cashu_id: str = None +): + cashu = await get_cashu(cashu_id) + + if not cashu: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + + lnurl = ( + lnurl_data.lnurl.replace("lnurlw://", "") + .replace("lightning://", "") + .replace("LIGHTNING://", "") + .replace("lightning:", "") + .replace("LIGHTNING:", "") + ) + + if lnurl.lower().startswith("lnurl"): + lnurl = decode_lnurl(lnurl) + else: + lnurl = "https://" + lnurl + + async with httpx.AsyncClient() as client: + try: + r = await client.get(lnurl, follow_redirects=True) + if r.is_error: + lnurl_response = {"success": False, "detail": "Error loading"} + else: + resp = r.json() + if resp["tag"] != "withdrawRequest": + lnurl_response = {"success": False, "detail": "Wrong tag type"} + else: + r2 = await client.get( + resp["callback"], + follow_redirects=True, + params={ + "k1": resp["k1"], + "pr": payment_request, + }, + ) + resp2 = r2.json() + if r2.is_error: + lnurl_response = { + "success": False, + "detail": "Error loading callback", + } + elif resp2["status"] == "ERROR": + lnurl_response = {"success": False, "detail": resp2["reason"]} + else: + lnurl_response = {"success": True, "detail": resp2} + except (httpx.ConnectError, httpx.RequestError): + lnurl_response = {"success": False, "detail": "Unexpected error occurred"} + + return lnurl_response + + +@cashu_ext.get( + "/api/v1/cashus/{cashu_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK +) +async def api_cashu_check_invoice(cashu_id: str, payment_hash: str): + cashu = await get_cashu(cashu_id) + if not cashu: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + try: + status = await api_payment(payment_hash) + + except Exception as exc: + logger.error(exc) + return {"paid": False} + return status