From 8db94595012fc6288dbb98c89d94e41845f6a83c Mon Sep 17 00:00:00 2001 From: benarc Date: Wed, 13 Oct 2021 17:08:48 +0100 Subject: [PATCH] Init --- lnbits/extensions/satsdice/README.md | 5 + lnbits/extensions/satsdice/__init__.py | 14 + lnbits/extensions/satsdice/config.json | 6 + lnbits/extensions/satsdice/crud.py | 301 ++++++++++ lnbits/extensions/satsdice/lnurl.py | 183 ++++++ lnbits/extensions/satsdice/migrations.py | 73 +++ lnbits/extensions/satsdice/models.py | 122 ++++ .../templates/satsdice/_api_docs.html | 194 +++++++ .../satsdice/templates/satsdice/_lnurl.html | 29 + .../satsdice/templates/satsdice/display.html | 63 +++ .../templates/satsdice/displaywin.html | 56 ++ .../satsdice/templates/satsdice/error.html | 48 ++ .../satsdice/templates/satsdice/index.html | 526 ++++++++++++++++++ lnbits/extensions/satsdice/views.py | 128 +++++ lnbits/extensions/satsdice/views_api.py | 265 +++++++++ 15 files changed, 2013 insertions(+) create mode 100644 lnbits/extensions/satsdice/README.md create mode 100644 lnbits/extensions/satsdice/__init__.py create mode 100644 lnbits/extensions/satsdice/config.json create mode 100644 lnbits/extensions/satsdice/crud.py create mode 100644 lnbits/extensions/satsdice/lnurl.py create mode 100644 lnbits/extensions/satsdice/migrations.py create mode 100644 lnbits/extensions/satsdice/models.py create mode 100644 lnbits/extensions/satsdice/templates/satsdice/_api_docs.html create mode 100644 lnbits/extensions/satsdice/templates/satsdice/_lnurl.html create mode 100644 lnbits/extensions/satsdice/templates/satsdice/display.html create mode 100644 lnbits/extensions/satsdice/templates/satsdice/displaywin.html create mode 100644 lnbits/extensions/satsdice/templates/satsdice/error.html create mode 100644 lnbits/extensions/satsdice/templates/satsdice/index.html create mode 100644 lnbits/extensions/satsdice/views.py create mode 100644 lnbits/extensions/satsdice/views_api.py diff --git a/lnbits/extensions/satsdice/README.md b/lnbits/extensions/satsdice/README.md new file mode 100644 index 00000000..c2419930 --- /dev/null +++ b/lnbits/extensions/satsdice/README.md @@ -0,0 +1,5 @@ +# satsdice + +## Create staic LNURL powered satsdices + +Gambling is dangerous, flip responsibly diff --git a/lnbits/extensions/satsdice/__init__.py b/lnbits/extensions/satsdice/__init__.py new file mode 100644 index 00000000..b991b135 --- /dev/null +++ b/lnbits/extensions/satsdice/__init__.py @@ -0,0 +1,14 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_satsdice") + + +satsdice_ext: Blueprint = Blueprint( + "satsdice", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa +from .lnurl import * # noqa diff --git a/lnbits/extensions/satsdice/config.json b/lnbits/extensions/satsdice/config.json new file mode 100644 index 00000000..e4c2eddb --- /dev/null +++ b/lnbits/extensions/satsdice/config.json @@ -0,0 +1,6 @@ +{ + "name": "Sats Dice", + "short_description": "LNURL Satoshi dice", + "icon": "casino", + "contributors": ["arcbtc"] +} diff --git a/lnbits/extensions/satsdice/crud.py b/lnbits/extensions/satsdice/crud.py new file mode 100644 index 00000000..78983142 --- /dev/null +++ b/lnbits/extensions/satsdice/crud.py @@ -0,0 +1,301 @@ +from datetime import datetime +from typing import List, Optional, Union +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import satsdiceWithdraw, HashCheck, satsdiceLink, satsdicePayment + +##################SATSDICE PAY LINKS + + +async def create_satsdice_pay( + *, + wallet_id: str, + title: str, + base_url: str, + min_bet: str, + max_bet: str, + multiplier: int = 0, + chance: float = 0, + haircut: int = 0, +) -> satsdiceLink: + satsdice_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO satsdice.satsdice_pay ( + id, + wallet, + title, + base_url, + min_bet, + max_bet, + amount, + served_meta, + served_pr, + multiplier, + chance, + haircut, + open_time + ) + VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, ?, ?) + """, + ( + satsdice_id, + wallet_id, + title, + base_url, + min_bet, + max_bet, + multiplier, + chance, + haircut, + int(datetime.now().timestamp()), + ), + ) + link = await get_satsdice_pay(satsdice_id) + assert link, "Newly created link couldn't be retrieved" + return link + + +async def get_satsdice_pay(link_id: str) -> Optional[satsdiceLink]: + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,) + ) + return satsdiceLink.from_row(row) if row else None + + +async def get_satsdice_pays(wallet_ids: Union[str, List[str]]) -> List[satsdiceLink]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f""" + SELECT * FROM satsdice.satsdice_pay WHERE wallet IN ({q}) + ORDER BY id + """, + (*wallet_ids,), + ) + + return [satsdiceLink.from_row(row) for row in rows] + + +async def update_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?", + (*kwargs.values(), link_id), + ) + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,) + ) + return satsdiceLink.from_row(row) if row else None + + +async def increment_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]: + q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?", + (*kwargs.values(), link_id), + ) + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,) + ) + return satsdiceLink.from_row(row) if row else None + + +async def delete_satsdice_pay(link_id: int) -> None: + await db.execute("DELETE FROM satsdice.satsdice_pay WHERE id = ?", (link_id,)) + + +##################SATSDICE PAYMENT LINKS + + +async def create_satsdice_payment( + *, satsdice_pay: str, value: int, payment_hash: str +) -> satsdicePayment: + await db.execute( + """ + INSERT INTO satsdice.satsdice_payment ( + payment_hash, + satsdice_pay, + value, + paid, + lost + ) + VALUES (?, ?, ?, ?, ?) + """, + ( + payment_hash, + satsdice_pay, + value, + False, + False, + ), + ) + payment = await get_satsdice_payment(payment_hash) + assert payment, "Newly created withdraw couldn't be retrieved" + return payment + + +async def get_satsdice_payment(payment_hash: str) -> Optional[satsdicePayment]: + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_payment WHERE payment_hash = ?", + (payment_hash,), + ) + return satsdicePayment.from_row(row) if row else None + + +async def update_satsdice_payment( + payment_hash: int, **kwargs +) -> Optional[satsdicePayment]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + await db.execute( + f"UPDATE satsdice.satsdice_payment SET {q} WHERE payment_hash = ?", + (bool(*kwargs.values()), payment_hash), + ) + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_payment WHERE payment_hash = ?", + (payment_hash,), + ) + return satsdicePayment.from_row(row) if row else None + + +##################SATSDICE WITHDRAW LINKS + + +async def create_satsdice_withdraw( + *, payment_hash: str, satsdice_pay: str, value: int, used: int +) -> satsdiceWithdraw: + await db.execute( + """ + INSERT INTO satsdice.satsdice_withdraw ( + id, + satsdice_pay, + value, + unique_hash, + k1, + open_time, + used + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + payment_hash, + satsdice_pay, + value, + urlsafe_short_hash(), + urlsafe_short_hash(), + int(datetime.now().timestamp()), + used, + ), + ) + withdraw = await get_satsdice_withdraw(payment_hash, 0) + assert withdraw, "Newly created withdraw couldn't be retrieved" + return withdraw + + +async def get_satsdice_withdraw(withdraw_id: str, num=0) -> Optional[satsdiceWithdraw]: + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_withdraw WHERE id = ?", (withdraw_id,) + ) + if not row: + return None + + withdraw = [] + for item in row: + withdraw.append(item) + withdraw.append(num) + return satsdiceWithdraw.from_row(row) + + +async def get_satsdice_withdraw_by_hash( + unique_hash: str, num=0 +) -> Optional[satsdiceWithdraw]: + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_withdraw WHERE unique_hash = ?", + (unique_hash,), + ) + if not row: + return None + + withdraw = [] + for item in row: + withdraw.append(item) + withdraw.append(num) + return satsdiceWithdraw.from_row(row) + + +async def get_satsdice_withdraws( + wallet_ids: Union[str, List[str]] +) -> List[satsdiceWithdraw]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM satsdice.satsdice_withdraw WHERE wallet IN ({q})", + (*wallet_ids,), + ) + + return [satsdiceWithdraw.from_row(row) for row in rows] + + +async def update_satsdice_withdraw( + withdraw_id: str, **kwargs +) -> Optional[satsdiceWithdraw]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE satsdice.satsdice_withdraw SET {q} WHERE id = ?", + (*kwargs.values(), withdraw_id), + ) + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_withdraw WHERE id = ?", (withdraw_id,) + ) + return satsdiceWithdraw.from_row(row) if row else None + + +async def delete_satsdice_withdraw(withdraw_id: str) -> None: + await db.execute( + "DELETE FROM satsdice.satsdice_withdraw WHERE id = ?", (withdraw_id,) + ) + + +async def create_withdraw_hash_check( + the_hash: str, + lnurl_id: str, +) -> HashCheck: + await db.execute( + """ + INSERT INTO satsdice.hash_checkw ( + id, + lnurl_id + ) + VALUES (?, ?) + """, + ( + the_hash, + lnurl_id, + ), + ) + hashCheck = await get_withdraw_hash_checkw(the_hash, lnurl_id) + return hashCheck + + +async def get_withdraw_hash_checkw(the_hash: str, lnurl_id: str) -> Optional[HashCheck]: + rowid = await db.fetchone( + "SELECT * FROM satsdice.hash_checkw WHERE id = ?", (the_hash,) + ) + rowlnurl = await db.fetchone( + "SELECT * FROM satsdice.hash_checkw WHERE lnurl_id = ?", (lnurl_id,) + ) + if not rowlnurl: + await create_withdraw_hash_check(the_hash, lnurl_id) + return {"lnurl": True, "hash": False} + else: + if not rowid: + await create_withdraw_hash_check(the_hash, lnurl_id) + return {"lnurl": True, "hash": False} + else: + return {"lnurl": True, "hash": True} diff --git a/lnbits/extensions/satsdice/lnurl.py b/lnbits/extensions/satsdice/lnurl.py new file mode 100644 index 00000000..1548de98 --- /dev/null +++ b/lnbits/extensions/satsdice/lnurl.py @@ -0,0 +1,183 @@ +import shortuuid # type: ignore +import hashlib +import math +from http import HTTPStatus +from datetime import datetime +from quart import jsonify, url_for, request +from lnbits.core.services import pay_invoice, create_invoice + +from lnbits.utils.exchange_rates import get_fiat_rate_satoshis + +from . import satsdice_ext +from .crud import ( + get_satsdice_withdraw_by_hash, + update_satsdice_withdraw, + get_satsdice_pay, + create_satsdice_payment, +) +from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore + + +##############LNURLP STUFF + + +@satsdice_ext.route("/api/v1/lnurlp/", methods=["GET"]) +async def api_lnurlp_response(link_id): + link = await get_satsdice_pay(link_id) + if not link: + return ( + jsonify({"status": "ERROR", "reason": "LNURL-payy not found."}), + HTTPStatus.OK, + ) + resp = LnurlPayResponse( + callback=url_for( + "satsdice.api_lnurlp_callback", link_id=link.id, _external=True + ), + min_sendable=math.ceil(link.min_bet * 1) * 1000, + max_sendable=round(link.max_bet * 1) * 1000, + metadata=link.lnurlpay_metadata, + ) + params = resp.dict() + + return jsonify(params), HTTPStatus.OK + + +@satsdice_ext.route("/api/v1/lnurlp/cb/", methods=["GET"]) +async def api_lnurlp_callback(link_id): + link = await get_satsdice_pay(link_id) + if not link: + return ( + jsonify({"status": "ERROR", "reason": "LNUeL-pay not found."}), + HTTPStatus.OK, + ) + + min, max = link.min_bet, link.max_bet + min = link.min_bet * 1000 + max = link.max_bet * 1000 + + amount_received = int(request.args.get("amount") or 0) + if amount_received < min: + return ( + jsonify( + LnurlErrorResponse( + reason=f"Amount {amount_received} is smaller than minimum {min}." + ).dict() + ), + HTTPStatus.OK, + ) + elif amount_received > max: + return ( + jsonify( + LnurlErrorResponse( + reason=f"Amount {amount_received} is greater than maximum {max}." + ).dict() + ), + HTTPStatus.OK, + ) + + payment_hash, payment_request = await create_invoice( + wallet_id=link.wallet, + amount=int(amount_received / 1000), + memo="Satsdice bet", + description_hash=hashlib.sha256( + link.lnurlpay_metadata.encode("utf-8") + ).digest(), + extra={"tag": "satsdice", "link": link.id, "comment": "comment"}, + ) + + success_action = link.success_action(payment_hash) + link = await create_satsdice_payment( + satsdice_pay=link.id, value=amount_received / 1000, payment_hash=payment_hash + ) + if success_action: + resp = LnurlPayActionResponse( + pr=payment_request, + success_action=success_action, + routes=[], + ) + else: + resp = LnurlPayActionResponse( + pr=payment_request, + routes=[], + ) + + return jsonify(resp.dict()), HTTPStatus.OK + + +##############LNURLW STUFF + + +@satsdice_ext.route("/api/v1/lnurlw/", methods=["GET"]) +async def api_lnurlw_response(unique_hash): + link = await get_satsdice_withdraw_by_hash(unique_hash) + + if not link: + return ( + jsonify({"status": "ERROR", "reason": "LNURL-satsdice not found."}), + HTTPStatus.OK, + ) + + if link.used: + return ( + jsonify({"status": "ERROR", "reason": "satsdice is spent."}), + HTTPStatus.OK, + ) + + return jsonify(link.lnurl_response.dict()), HTTPStatus.OK + + +# CALLBACK + + +@satsdice_ext.route("/api/v1/lnurlw/cb/", methods=["GET"]) +async def api_lnurlw_callback(unique_hash): + link = await get_satsdice_withdraw_by_hash(unique_hash) + paylink = await get_satsdice_pay(link.satsdice_pay) + k1 = request.args.get("k1", type=str) + payment_request = request.args.get("pr", type=str) + now = int(datetime.now().timestamp()) + + if not link: + return ( + jsonify({"status": "ERROR", "reason": "LNURL-satsdice not found."}), + HTTPStatus.OK, + ) + + if link.used: + return ( + jsonify({"status": "ERROR", "reason": "satsdice is spent."}), + HTTPStatus.OK, + ) + + if link.k1 != k1: + return jsonify({"status": "ERROR", "reason": "Bad request."}), HTTPStatus.OK + + if now < link.open_time: + return ( + jsonify( + {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."} + ), + HTTPStatus.OK, + ) + + try: + await update_satsdice_withdraw(link.id, used=1) + + await pay_invoice( + wallet_id=paylink.wallet, + payment_request=payment_request, + max_sat=link.value, + extra={"tag": "withdraw"}, + ) + + except ValueError as e: + await update_satsdice_withdraw(link.id, used=1) + return jsonify({"status": "ERROR", "reason": str(e)}) + except PermissionError: + await update_satsdice_withdraw(link.id, used=1) + return jsonify({"status": "ERROR", "reason": "satsdice link is empty."}) + except Exception as e: + await update_satsdice_withdraw(link.id, used=1) + return jsonify({"status": "ERROR", "reason": str(e)}) + + return jsonify({"status": "OK"}), HTTPStatus.OK diff --git a/lnbits/extensions/satsdice/migrations.py b/lnbits/extensions/satsdice/migrations.py new file mode 100644 index 00000000..61298241 --- /dev/null +++ b/lnbits/extensions/satsdice/migrations.py @@ -0,0 +1,73 @@ +async def m001_initial(db): + """ + Creates an improved satsdice table and migrates the existing data. + """ + await db.execute( + """ + CREATE TABLE satsdice.satsdice_pay ( + id TEXT PRIMARY KEY, + wallet TEXT, + title TEXT, + min_bet INTEGER, + max_bet INTEGER, + amount INTEGER DEFAULT 0, + served_meta INTEGER NOT NULL, + served_pr INTEGER NOT NULL, + multiplier FLOAT, + haircut FLOAT, + chance FLOAT, + base_url TEXT, + open_time INTEGER + ); + """ + ) + + +async def m002_initial(db): + """ + Creates an improved satsdice table and migrates the existing data. + """ + await db.execute( + """ + CREATE TABLE satsdice.satsdice_withdraw ( + id TEXT PRIMARY KEY, + satsdice_pay TEXT, + value INTEGER DEFAULT 1, + unique_hash TEXT UNIQUE, + k1 TEXT, + open_time INTEGER, + used INTEGER DEFAULT 0 + ); + """ + ) + + +async def m003_initial(db): + """ + Creates an improved satsdice table and migrates the existing data. + """ + await db.execute( + """ + CREATE TABLE satsdice.satsdice_payment ( + payment_hash TEXT PRIMARY KEY, + satsdice_pay TEXT, + value INTEGER, + paid BOOL DEFAULT FALSE, + lost BOOL DEFAULT FALSE + ); + """ + ) + + +async def m004_make_hash_check(db): + """ + Creates a hash check table. + """ + await db.execute( + """ + CREATE TABLE satsdice.hash_checkw ( + id TEXT PRIMARY KEY, + lnurl_id TEXT + ); + """ + ) diff --git a/lnbits/extensions/satsdice/models.py b/lnbits/extensions/satsdice/models.py new file mode 100644 index 00000000..5fe732dd --- /dev/null +++ b/lnbits/extensions/satsdice/models.py @@ -0,0 +1,122 @@ +import json +from quart import url_for +from lnurl import Lnurl, LnurlWithdrawResponse, encode as lnurl_encode # type: ignore +from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult +from lnurl.types import LnurlPayMetadata # type: ignore +from sqlite3 import Row +from typing import NamedTuple, Optional, Dict +import shortuuid # type: ignore + + +class satsdiceLink(NamedTuple): + id: int + wallet: str + title: str + min_bet: int + max_bet: int + amount: int + served_meta: int + served_pr: int + multiplier: float + haircut: float + chance: float + base_url: str + open_time: int + + @classmethod + def from_row(cls, row: Row) -> "satsdiceLink": + data = dict(row) + return cls(**data) + + @property + def lnurl(self) -> Lnurl: + url = url_for("satsdice.api_lnurlp_response", link_id=self.id, _external=True) + return lnurl_encode(url) + + @property + def lnurlpay_metadata(self) -> LnurlPayMetadata: + return LnurlPayMetadata(json.dumps([["text/plain", self.title]])) + + def success_action(self, payment_hash: str) -> Optional[Dict]: + url = url_for( + "satsdice.displaywin", + link_id=self.id, + payment_hash=payment_hash, + _external=True, + ) + # url: ParseResult = urlparse(url) + print(url) + # qs: Dict = parse_qs(url.query) + # qs["payment_hash"] = payment_hash + # url = url._replace(query=urlencode(qs, doseq=True)) + return { + "tag": "url", + "description": "Check the attached link", + "url": url, + } + + +class satsdicePayment(NamedTuple): + payment_hash: str + satsdice_pay: str + value: int + paid: bool + lost: bool + + @classmethod + def from_row(cls, row: Row) -> "satsdicePayment": + data = dict(row) + return cls(**data) + + +class satsdiceWithdraw(NamedTuple): + id: str + satsdice_pay: str + value: int + unique_hash: str + k1: str + open_time: int + used: int + + @classmethod + def from_row(cls, row: Row) -> "satsdiceWithdraw": + data = dict(row) + return cls(**data) + + @property + def is_spent(self) -> bool: + return self.used >= 1 + + @property + def lnurl(self) -> Lnurl: + url = url_for( + "satsdice.api_lnurlw_response", + unique_hash=self.unique_hash, + _external=True, + ) + + return lnurl_encode(url) + + @property + def lnurl_response(self) -> LnurlWithdrawResponse: + url = url_for( + "satsdice.api_lnurlw_callback", + unique_hash=self.unique_hash, + _external=True, + ) + return LnurlWithdrawResponse( + callback=url, + k1=self.k1, + minWithdrawable=self.value * 1000, + maxWithdrawable=self.value * 1000, + default_description="Satsdice winnings!", + ) + + +class HashCheck(NamedTuple): + id: str + lnurl_id: str + + @classmethod + def from_row(cls, row: Row) -> "Hash": + return cls(**dict(row)) diff --git a/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html new file mode 100644 index 00000000..0f22784e --- /dev/null +++ b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html @@ -0,0 +1,194 @@ + + + + + GET /satsdice/api/v1/links +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<satsdice_link_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /satsdice/api/v1/links/<satsdice_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/links/<satsdice_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST /satsdice/api/v1/links +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"title": <string>, "min_satsdiceable": <integer>, + "max_satsdiceable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>} +
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/links -d '{"title": + <string>, "min_satsdiceable": <integer>, + "max_satsdiceable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /satsdice/api/v1/links/<satsdice_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"title": <string>, "min_satsdiceable": <integer>, + "max_satsdiceable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>} +
+ Returns 200 OK (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X PUT {{ request.url_root }}api/v1/links/<satsdice_id> -d + '{"title": <string>, "min_satsdiceable": <integer>, + "max_satsdiceable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /satsdice/api/v1/links/<satsdice_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root }}api/v1/links/<satsdice_id> + -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /satsdice/api/v1/links/<the_hash>/<lnurl_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ {"status": <bool>} +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /satsdice/img/<lnurl_id> +
Curl example
+ curl -X GET {{ request.url_root }}/satsdice/img/<lnurl_id>" + +
+
+
+
diff --git a/lnbits/extensions/satsdice/templates/satsdice/_lnurl.html b/lnbits/extensions/satsdice/templates/satsdice/_lnurl.html new file mode 100644 index 00000000..20b67cab --- /dev/null +++ b/lnbits/extensions/satsdice/templates/satsdice/_lnurl.html @@ -0,0 +1,29 @@ + + + +

+ WARNING: LNURL must be used over https or TOR
+ LNURL is a range of lightning-network standards that allow us to use + lightning-network differently. An LNURL satsdice is the permission for + someone to pull a certain amount of funds from a lightning wallet. In + this extension time is also added - an amount can be satsdice over a + period of time. A typical use case for an LNURL satsdice is a faucet, + although it is a very powerful technology, with much further reaching + implications. For example, an LNURL satsdice could be minted to pay for + a subscription service. +

+

+ Exploring LNURL and finding use cases, is really helping inform + lightning protocol development, rather than the protocol dictating how + lightning-network should be engaged with. +

+ Check + Awesome LNURL + for further information. +
+
+
diff --git a/lnbits/extensions/satsdice/templates/satsdice/display.html b/lnbits/extensions/satsdice/templates/satsdice/display.html new file mode 100644 index 00000000..d4238e30 --- /dev/null +++ b/lnbits/extensions/satsdice/templates/satsdice/display.html @@ -0,0 +1,63 @@ +{% extends "public.html" %} {% block page %} +
+
+ + + +
+ Copy Satsdice LNURL +
+
+
+
+
+ + +
+ Chance of winning: {% raw %}{{ chance }}{% endraw %}, Amount + multiplier: {{ multiplier }} +
+

+ Use a LNURL compatible bitcoin wallet to play the satsdice. +

+
+ + + {% include "satsdice/_lnurl.html" %} + +
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/satsdice/templates/satsdice/displaywin.html b/lnbits/extensions/satsdice/templates/satsdice/displaywin.html new file mode 100644 index 00000000..aa4f1375 --- /dev/null +++ b/lnbits/extensions/satsdice/templates/satsdice/displaywin.html @@ -0,0 +1,56 @@ +{% extends "public.html" %} {% block page %} +
+
+ + + +
+ Copy winnings LNURL +
+
+
+
+
+ + +
+ Congrats! You have won {{ value }}sats (you must claim the sats now) +
+

+ Use a LNURL compatible bitcoin wallet to play the satsdice. +

+
+ + + {% include "satsdice/_lnurl.html" %} + +
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/satsdice/templates/satsdice/error.html b/lnbits/extensions/satsdice/templates/satsdice/error.html new file mode 100644 index 00000000..1c8fc618 --- /dev/null +++ b/lnbits/extensions/satsdice/templates/satsdice/error.html @@ -0,0 +1,48 @@ +{% extends "public.html" %} {% from "macros.jinja" import window_vars with +context %}{% block page %} +
+
+ + +
+ {% if lost %} +
+ You lost. Play again? +
+ {% endif %} {% if paid %} +
+ Winnings spent. Play again? +
+ {% endif %} +
+ +
+
+
+
+
+
+{% endblock %} {% block scripts %}{{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/satsdice/templates/satsdice/index.html b/lnbits/extensions/satsdice/templates/satsdice/index.html new file mode 100644 index 00000000..3e8573b8 --- /dev/null +++ b/lnbits/extensions/satsdice/templates/satsdice/index.html @@ -0,0 +1,526 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New satsdice + + + + + +
+
+
satsdices
+
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} Sats Dice extension +
+
+ + + + {% include "lnurlp/_api_docs.html" %} + + {% include "lnurlp/_lnurl.html" %} + + +
+
+ + + + + + + {% raw %} + + +
+
+ +
+
+ +
+
+ + +
+ + Multipler: x{{ multiValue }}, Chance of winning: {{ chanceValueCalc + | percent }} + + + +
+ +
+ Update flip link + Create satsdice + Cancel +
+
+
+
+ + + + + + +

+ ID: {{ qrCodeDialog.data.id }}
+ Amount: {{ qrCodeDialog.data.amount }}
+ {{ qrCodeDialog.data.currency }} price: {{ + fiatRates[qrCodeDialog.data.currency] ? + fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}
+ Accepts comments: {{ qrCodeDialog.data.comments }}
+ Dispatches webhook to: {{ qrCodeDialog.data.webhook + }}
+ On success: {{ qrCodeDialog.data.success }}
+

+ {% endraw %} +
+ Copy Satsdice LNURL + Copy shareable link + + Launch shareable link + Print Satsdice + Close +
+
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + + + +{% endblock %} diff --git a/lnbits/extensions/satsdice/views.py b/lnbits/extensions/satsdice/views.py new file mode 100644 index 00000000..2e30bc39 --- /dev/null +++ b/lnbits/extensions/satsdice/views.py @@ -0,0 +1,128 @@ +from quart import g, abort, render_template +from http import HTTPStatus +import pyqrcode +from io import BytesIO +from lnbits.decorators import check_user_exists, validate_uuids +from lnbits.core.crud import get_user, get_standalone_payment +from lnbits.core.services import check_invoice_status +import random + +from . import satsdice_ext +from .crud import ( + get_satsdice_pay, + update_satsdice_payment, + get_satsdice_payment, + create_satsdice_withdraw, + get_satsdice_withdraw, +) + + +@satsdice_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("satsdice/index.html", user=g.user) + + +@satsdice_ext.route("/") +async def display(link_id): + link = await get_satsdice_pay(link_id) or abort( + HTTPStatus.NOT_FOUND, "satsdice link does not exist." + ) + return await render_template( + "satsdice/display.html", + chance=link.chance, + multiplier=link.multiplier, + lnurl=link.lnurl, + unique=True, + ) + + +@satsdice_ext.route("/win//") +async def displaywin(link_id, payment_hash): + satsdicelink = await get_satsdice_pay(link_id) or abort( + HTTPStatus.NOT_FOUND, "satsdice link does not exist." + ) + withdrawLink = await get_satsdice_withdraw(payment_hash) + + if withdrawLink: + return await render_template( + "satsdice/displaywin.html", + value=withdrawLink.value, + chance=satsdicelink.chance, + multiplier=satsdicelink.multiplier, + lnurl=withdrawLink.lnurl, + paid=False, + lost=False, + ) + + payment = await get_standalone_payment(payment_hash) or abort( + HTTPStatus.NOT_FOUND, "satsdice link does not exist." + ) + + if payment.pending == 1: + await check_invoice_status(payment.wallet_id, payment_hash) + payment = await get_standalone_payment(payment_hash) or abort( + HTTPStatus.NOT_FOUND, "satsdice link does not exist." + ) + if payment.pending == 1: + print("pending") + return await render_template( + "satsdice/error.html", link=satsdicelink.id, paid=False, lost=False + ) + + await update_satsdice_payment(payment_hash, paid=1) + + paylink = await get_satsdice_payment(payment_hash) or abort( + HTTPStatus.NOT_FOUND, "satsdice link does not exist." + ) + + if paylink.lost == 1: + print("lost") + return await render_template( + "satsdice/error.html", link=satsdicelink.id, paid=False, lost=True + ) + rand = random.randint(0, 100) + chance = satsdicelink.chance + if rand > chance: + await update_satsdice_payment(payment_hash, lost=1) + return await render_template( + "satsdice/error.html", link=satsdicelink.id, paid=False, lost=True + ) + + withdrawLink = await create_satsdice_withdraw( + payment_hash=payment_hash, + satsdice_pay=satsdicelink.id, + value=paylink.value * satsdicelink.multiplier, + used=0, + ) + + return await render_template( + "satsdice/displaywin.html", + value=withdrawLink.value, + chance=satsdicelink.chance, + multiplier=satsdicelink.multiplier, + lnurl=withdrawLink.lnurl, + paid=False, + lost=False, + ) + + +@satsdice_ext.route("/img/") +async def img(link_id): + link = await get_satsdice_pay(link_id) or abort( + HTTPStatus.NOT_FOUND, "satsdice link does not exist." + ) + qr = pyqrcode.create(link.lnurl) + stream = BytesIO() + qr.svg(stream, scale=3) + return ( + stream.getvalue(), + 200, + { + "Content-Type": "image/svg+xml", + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + ) diff --git a/lnbits/extensions/satsdice/views_api.py b/lnbits/extensions/satsdice/views_api.py new file mode 100644 index 00000000..90e7a8c2 --- /dev/null +++ b/lnbits/extensions/satsdice/views_api.py @@ -0,0 +1,265 @@ +from quart import g, jsonify, request +from http import HTTPStatus +from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore + +from lnbits.core.crud import get_user +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from . import satsdice_ext +from .crud import ( + create_satsdice_pay, + get_satsdice_pay, + get_satsdice_pays, + update_satsdice_pay, + delete_satsdice_pay, + create_satsdice_withdraw, + get_satsdice_withdraw, + get_satsdice_withdraws, + update_satsdice_withdraw, + delete_satsdice_withdraw, + create_withdraw_hash_check, +) + +################LNURL pay + + +@satsdice_ext.route("/api/v1/links", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_links(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + + try: + return ( + jsonify( + [ + {**link._asdict(), **{"lnurl": link.lnurl}} + for link in await get_satsdice_pays(wallet_ids) + ] + ), + HTTPStatus.OK, + ) + except LnurlInvalidUrl: + return ( + jsonify( + { + "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor." + } + ), + HTTPStatus.UPGRADE_REQUIRED, + ) + + +@satsdice_ext.route("/api/v1/links/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_link_retrieve(link_id): + link = await get_satsdice_pay(link_id) + + if not link: + return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND + + if link.wallet != g.wallet.id: + return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN + + return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK + + +@satsdice_ext.route("/api/v1/links", methods=["POST"]) +@satsdice_ext.route("/api/v1/links/", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "title": {"type": "string", "empty": False, "required": True}, + "base_url": {"type": "string", "empty": False, "required": True}, + "min_bet": {"type": "number", "required": True}, + "max_bet": {"type": "number", "required": True}, + "multiplier": {"type": "number", "required": True}, + "chance": {"type": "float", "required": True}, + "haircut": {"type": "number", "required": True}, + } +) +async def api_link_create_or_update(link_id=None): + if g.data["min_bet"] > g.data["max_bet"]: + return jsonify({"message": "Min is greater than max."}), HTTPStatus.BAD_REQUEST + if link_id: + link = await get_satsdice_pay(link_id) + + if not link: + return ( + jsonify({"message": "Satsdice does not exist."}), + HTTPStatus.NOT_FOUND, + ) + + if link.wallet != g.wallet.id: + return ( + jsonify({"message": "Come on, seriously, this isn't your satsdice!"}), + HTTPStatus.FORBIDDEN, + ) + + link = await update_satsdice_pay(link_id, **g.data) + else: + link = await create_satsdice_pay(wallet_id=g.wallet.id, **g.data) + + return ( + jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), + HTTPStatus.OK if link_id else HTTPStatus.CREATED, + ) + + +@satsdice_ext.route("/api/v1/links/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_link_delete(link_id): + link = await get_satsdice_pay(link_id) + + if not link: + return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND + + if link.wallet != g.wallet.id: + return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN + + await delete_satsdice_pay(link_id) + + return "", HTTPStatus.NO_CONTENT + + +##########LNURL withdraw + + +@satsdice_ext.route("/api/v1/withdraws", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_withdraws(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + try: + return ( + jsonify( + [ + { + **withdraw._asdict(), + **{"lnurl": withdraw.lnurl}, + } + for withdraw in await get_satsdice_withdraws(wallet_ids) + ] + ), + HTTPStatus.OK, + ) + except LnurlInvalidUrl: + return ( + jsonify( + { + "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor." + } + ), + HTTPStatus.UPGRADE_REQUIRED, + ) + + +@satsdice_ext.route("/api/v1/withdraws/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_withdraw_retrieve(withdraw_id): + withdraw = await get_satsdice_withdraw(withdraw_id, 0) + + if not withdraw: + return ( + jsonify({"message": "satsdice withdraw does not exist."}), + HTTPStatus.NOT_FOUND, + ) + + if withdraw.wallet != g.wallet.id: + return ( + jsonify({"message": "Not your satsdice withdraw."}), + HTTPStatus.FORBIDDEN, + ) + + return jsonify({**withdraw._asdict(), **{"lnurl": withdraw.lnurl}}), HTTPStatus.OK + + +@satsdice_ext.route("/api/v1/withdraws", methods=["POST"]) +@satsdice_ext.route("/api/v1/withdraws/", methods=["PUT"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "title": {"type": "string", "empty": False, "required": True}, + "min_satsdiceable": {"type": "integer", "min": 1, "required": True}, + "max_satsdiceable": {"type": "integer", "min": 1, "required": True}, + "uses": {"type": "integer", "min": 1, "required": True}, + "wait_time": {"type": "integer", "min": 1, "required": True}, + "is_unique": {"type": "boolean", "required": True}, + } +) +async def api_withdraw_create_or_update(withdraw_id=None): + if g.data["max_satsdiceable"] < g.data["min_satsdiceable"]: + return ( + jsonify( + { + "message": "`max_satsdiceable` needs to be at least `min_satsdiceable`." + } + ), + HTTPStatus.BAD_REQUEST, + ) + + usescsv = "" + for i in range(g.data["uses"]): + if g.data["is_unique"]: + usescsv += "," + str(i + 1) + else: + usescsv += "," + str(1) + usescsv = usescsv[1:] + + if withdraw_id: + withdraw = await get_satsdice_withdraw(withdraw_id, 0) + if not withdraw: + return ( + jsonify({"message": "satsdice withdraw does not exist."}), + HTTPStatus.NOT_FOUND, + ) + if withdraw.wallet != g.wallet.id: + return ( + jsonify({"message": "Not your satsdice withdraw."}), + HTTPStatus.FORBIDDEN, + ) + withdraw = await update_satsdice_withdraw( + withdraw_id, **g.data, usescsv=usescsv, used=0 + ) + else: + withdraw = await create_satsdice_withdraw( + wallet_id=g.wallet.id, **g.data, usescsv=usescsv + ) + + return ( + jsonify({**withdraw._asdict(), **{"lnurl": withdraw.lnurl}}), + HTTPStatus.OK if withdraw_id else HTTPStatus.CREATED, + ) + + +@satsdice_ext.route("/api/v1/withdraws/", methods=["DELETE"]) +@api_check_wallet_key("admin") +async def api_withdraw_delete(withdraw_id): + withdraw = await get_satsdice_withdraw(withdraw_id) + + if not withdraw: + return ( + jsonify({"message": "satsdice withdraw does not exist."}), + HTTPStatus.NOT_FOUND, + ) + + if withdraw.wallet != g.wallet.id: + return ( + jsonify({"message": "Not your satsdice withdraw."}), + HTTPStatus.FORBIDDEN, + ) + + await delete_satsdice_withdraw(withdraw_id) + + return "", HTTPStatus.NO_CONTENT + + +@satsdice_ext.route("/api/v1/withdraws//", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_withdraw_hash_retrieve(the_hash, lnurl_id): + hashCheck = await get_withdraw_hash_check(the_hash, lnurl_id) + return jsonify(hashCheck), HTTPStatus.OK