Merge branch 'main' into default_ext_install

This commit is contained in:
callebtc 2023-02-20 16:30:30 +01:00
commit bb26f2c7f0
321 changed files with 230 additions and 44770 deletions

View file

@ -27,12 +27,11 @@ jobs:
- name: Install packages
run: |
poetry install
npm install prettier
- name: Check black
run: make checkblack
- name: Check isort
run: make checkisort
- uses: actions/setup-node@v3
- name: Check prettier
run: |
npm install prettier
make checkprettier
run: make checkprettier

2
.gitignore vendored
View file

@ -45,4 +45,4 @@ fly.toml
# Ignore extensions (post installable extension PR)
extensions/
upgrades/
upgrades/

10
.prettierignore Normal file
View file

@ -0,0 +1,10 @@
**/.git
**/.svn
**/.hg
**/node_modules
*.yml
**/lnbits/static/vendor
**/lnbits/static/bundle.*
**/lnbits/static/css/*

View file

@ -6,8 +6,8 @@ format: prettier isort black
check: mypy pyright pylint flake8 checkisort checkblack checkprettier
prettier: $(shell find lnbits -name "*.js" -o -name ".html")
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
prettier:
poetry run ./node_modules/.bin/prettier --write lnbits
pyright:
poetry run ./node_modules/.bin/pyright
@ -27,8 +27,8 @@ isort:
pylint:
poetry run pylint *.py lnbits/ tools/ tests/
checkprettier: $(shell find lnbits -name "*.js" -o -name ".html")
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
checkprettier:
poetry run ./node_modules/.bin/prettier --check lnbits
checkblack:
poetry run black --check .

View file

@ -2,10 +2,12 @@ import importlib
import re
from typing import Any
import httpx
from loguru import logger
from lnbits.db import Connection
from lnbits.extension_manager import Extension
from lnbits.settings import settings
from . import db as core_db
from .crud import update_migration_version
@ -42,3 +44,22 @@ async def run_migration(db: Connection, migrations_module: Any, current_version:
else:
async with core_db.connect() as conn:
await update_migration_version(conn, db_name, version)
async def stop_extension_background_work(ext_id: str, user: str):
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a DELETE enpoint at the root level of their API.
This function tries first to call the endpoint using `http` and if if fails it tries using `https`.
"""
async with httpx.AsyncClient() as client:
try:
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
await client.delete(url)
except Exception as ex:
logger.warning(ex)
try:
# try https
url = f"https://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
except Exception as ex:
logger.warning(ex)

View file

@ -38,7 +38,7 @@
filled
v-model="formData.lightning_invoice_expiry"
label="Invoice expiry (seconds)"
mask="#######"
mask="#######"
>
</q-input>
</div>

View file

@ -58,6 +58,5 @@
</div>
<br />
</div>
</q-card-section>
</q-tab-panel>

View file

@ -395,14 +395,23 @@
addExtensionsManifest() {
const addManifest = this.formAddExtensionsManifest.trim()
const manifests = this.formData.lnbits_extensions_manifests
if (addManifest && addManifest.length && !manifests.includes(addManifest)) {
this.formData.lnbits_extensions_manifests = [...manifests, addManifest]
if (
addManifest &&
addManifest.length &&
!manifests.includes(addManifest)
) {
this.formData.lnbits_extensions_manifests = [
...manifests,
addManifest
]
this.formAddExtensionsManifest = ''
}
},
removeExtensionsManifest(manifest) {
const manifests = this.formData.lnbits_extensions_manifests
this.formData.lnbits_extensions_manifests = manifests.filter(m => m !== manifest)
this.formData.lnbits_extensions_manifests = manifests.filter(
m => m !== manifest
)
},
restartServer() {
LNbits.api

View file

@ -29,7 +29,10 @@ from sse_starlette.sse import EventSourceResponse
from starlette.responses import RedirectResponse, StreamingResponse
from lnbits import bolt11, lnurl
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.helpers import (
migrate_extension_database,
stop_extension_background_work,
)
from lnbits.core.models import Payment, User, Wallet
from lnbits.decorators import (
WalletTypeInfo,
@ -729,7 +732,6 @@ async def websocket_update_get(item_id: str, data: str):
async def api_install_extension(
data: CreateExtension, user: User = Depends(check_admin)
):
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive
)
@ -752,6 +754,10 @@ async def api_install_extension(
await migrate_extension_database(extension, db_version)
await add_installed_extension(ext_info)
# call stop while the old routes are still active
await stop_extension_background_work(data.ext_id, user.id)
if data.ext_id not in settings.lnbits_deactivated_extensions:
settings.lnbits_deactivated_extensions += [data.ext_id]
@ -796,6 +802,9 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
)
try:
# call stop while the old routes are still active
await stop_extension_background_work(ext_id, user.id)
if ext_id not in settings.lnbits_deactivated_extensions:
settings.lnbits_deactivated_extensions += [ext_id]

View file

@ -1,11 +1,9 @@
import asyncio
import datetime
from http import HTTPStatus
from urllib.parse import urlparse
from fastapi import HTTPException
from loguru import logger
from starlette.requests import Request
from lnbits import bolt11
@ -14,14 +12,6 @@ from ..crud import get_standalone_payment
from ..tasks import api_invoice_listeners
@core_app.get("/.well-known/lnurlp/{username}")
async def lnaddress(username: str, request: Request):
from lnbits.extensions.lnaddress.lnurl import lnurl_response # type: ignore
domain = urlparse(str(request.url)).netloc
return await lnurl_response(username, domain, request)
@core_app.get("/public/v1/payment/{payment_hash}")
async def api_public_payment_longpolling(payment_hash):
payment = await get_standalone_payment(payment_hash)

View file

@ -51,13 +51,16 @@ class Extension(NamedTuple):
)
# All subdirectories in the current directory, not recursive.
class ExtensionManager:
def __init__(self):
self._disabled: List[str] = settings.lnbits_disabled_extensions
self._admin_only: List[str] = settings.lnbits_admin_extensions
self._extension_folders: List[str] = [
x[1] for x in os.walk(os.path.join(settings.lnbits_path, "extensions"))
][0]
p = Path(settings.lnbits_path, "extensions")
os.makedirs(p, exist_ok=True)
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
@property
def extensions(self) -> List[Extension]:
@ -70,11 +73,7 @@ class ExtensionManager:
ext for ext in self._extension_folders if ext not in self._disabled
]:
try:
with open(
os.path.join(
settings.lnbits_path, "extensions", extension, "config.json"
)
) as json_file:
with open(extension / "config.json") as json_file:
config = json.load(json_file)
is_valid = True
is_admin_only = True if extension in self._admin_only else False
@ -83,9 +82,10 @@ class ExtensionManager:
is_valid = False
is_admin_only = False
*_, extension_code = extension.parts
output.append(
Extension(
extension,
extension_code,
is_valid,
is_admin_only,
config.get("name"),

View file

@ -1,21 +0,0 @@
# Bleskomat Extension for lnbits
This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the [open-source DIY Bleskomat ATM project](https://github.com/samotari/bleskomat) as well as the [commercial Bleskomat ATM](https://www.bleskomat.com/).
## Connect Your Bleskomat ATM
* Click the "Add Bleskomat" button on this page to begin.
* Choose a wallet. This will be the wallet that is used to pay satoshis to your ATM customers.
* Choose the fiat currency. This should match the fiat currency that your ATM accepts.
* Pick an exchange rate provider. This is the API that will be used to query the fiat to satoshi exchange rate at the time your customer attempts to withdraw their funds.
* Set your ATM's fee percentage.
* Click the "Done" button.
* Find the new Bleskomat in the list and then click the export icon to download a new configuration file for your ATM.
* Copy the configuration file ("bleskomat.conf") to your ATM's SD card.
* Restart Your Bleskomat ATM. It should automatically reload the configurations from the SD card.
## How Does It Work?
Since the Bleskomat ATMs are designed to be offline, a cryptographic signing scheme is used to verify that the URL was generated by an authorized device. When one of your customers inserts fiat money into the device, a signed URL (lnurl-withdraw) is created and displayed as a QR code. Your customer scans the QR code with their lnurl-supporting mobile app, their mobile app communicates with the web API of lnbits to verify the signature, the fiat currency amount is converted to sats, the customer accepts the withdrawal, and finally lnbits will pay the customer from your lnbits wallet.

View file

@ -1,26 +0,0 @@
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_bleskomat")
bleskomat_static_files = [
{
"path": "/bleskomat/static",
"app": StaticFiles(packages=[("lnbits", "extensions/bleskomat/static")]),
"name": "bleskomat_static",
}
]
bleskomat_ext: APIRouter = APIRouter(prefix="/bleskomat", tags=["Bleskomat"])
def bleskomat_renderer():
return template_renderer(["lnbits/extensions/bleskomat/templates"])
from .lnurl_api import * # noqa: F401,F403
from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403

View file

@ -1,6 +0,0 @@
{
"name": "Bleskomat",
"short_description": "Connect a Bleskomat ATM to an lnbits",
"tile": "/bleskomat/static/image/bleskomat.png",
"contributors": ["chill117"]
}

View file

@ -1,113 +0,0 @@
import secrets
import time
from typing import List, Optional, Union
from uuid import uuid4
from . import db
from .helpers import generate_bleskomat_lnurl_hash
from .models import Bleskomat, BleskomatLnurl, CreateBleskomat
async def create_bleskomat(data: CreateBleskomat, wallet_id: str) -> Bleskomat:
bleskomat_id = uuid4().hex
api_key_id = secrets.token_hex(8)
api_key_secret = secrets.token_hex(32)
api_key_encoding = "hex"
await db.execute(
"""
INSERT INTO bleskomat.bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
bleskomat_id,
wallet_id,
api_key_id,
api_key_secret,
api_key_encoding,
data.name,
data.fiat_currency,
data.exchange_rate_provider,
data.fee,
),
)
bleskomat = await get_bleskomat(bleskomat_id)
assert bleskomat, "Newly created bleskomat couldn't be retrieved"
return bleskomat
async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]:
row = await db.fetchone(
"SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)
)
return Bleskomat(**row) if row else None
async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]:
row = await db.fetchone(
"SELECT * FROM bleskomat.bleskomats WHERE api_key_id = ?", (api_key_id,)
)
return Bleskomat(**row) if row else None
async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM bleskomat.bleskomats WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Bleskomat(**row) for row in rows]
async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE bleskomat.bleskomats SET {q} WHERE id = ?",
(*kwargs.values(), bleskomat_id),
)
row = await db.fetchone(
"SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)
)
return Bleskomat(**row) if row else None
async def delete_bleskomat(bleskomat_id: str) -> None:
await db.execute("DELETE FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,))
async def create_bleskomat_lnurl(
*, bleskomat: Bleskomat, secret: str, tag: str, params: str, uses: int = 1
) -> BleskomatLnurl:
bleskomat_lnurl_id = uuid4().hex
hash = generate_bleskomat_lnurl_hash(secret)
now = int(time.time())
await db.execute(
"""
INSERT INTO bleskomat.bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
bleskomat_lnurl_id,
bleskomat.id,
bleskomat.wallet,
hash,
tag,
params,
bleskomat.api_key_id,
uses,
uses,
now,
now,
),
)
bleskomat_lnurl = await get_bleskomat_lnurl(secret)
assert bleskomat_lnurl, "Newly created bleskomat LNURL couldn't be retrieved"
return bleskomat_lnurl
async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]:
hash = generate_bleskomat_lnurl_hash(secret)
row = await db.fetchone(
"SELECT * FROM bleskomat.bleskomat_lnurls WHERE hash = ?", (hash,)
)
return BleskomatLnurl(**row) if row else None

View file

@ -1,85 +0,0 @@
import json
import os
from typing import Callable, Union
import httpx
fiat_currencies = json.load(
open(
os.path.join(
os.path.dirname(os.path.realpath(__file__)), "fiat_currencies.json"
),
"r",
)
)
exchange_rate_providers: dict[
str, dict[str, Union[str, Callable[[dict, dict], str]]]
] = {
"bitfinex": {
"name": "Bitfinex",
"domain": "bitfinex.com",
"api_url": "https://api.bitfinex.com/v1/pubticker/{from}{to}",
"getter": lambda data, replacements: data["last_price"],
},
"bitstamp": {
"name": "Bitstamp",
"domain": "bitstamp.net",
"api_url": "https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
"getter": lambda data, replacements: data["last"],
},
"coinbase": {
"name": "Coinbase",
"domain": "coinbase.com",
"api_url": "https://api.coinbase.com/v2/exchange-rates?currency={FROM}",
"getter": lambda data, replacements: data["data"]["rates"][replacements["TO"]],
},
"coinmate": {
"name": "CoinMate",
"domain": "coinmate.io",
"api_url": "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}",
"getter": lambda data, replacements: data["data"]["last"],
},
"kraken": {
"name": "Kraken",
"domain": "kraken.com",
"api_url": "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
"getter": lambda data, replacements: data["result"][
"XXBTZ" + replacements["TO"]
]["c"][0],
},
}
exchange_rate_providers_serializable = {}
for ref, exchange_rate_provider in exchange_rate_providers.items():
exchange_rate_provider_serializable = {}
for key, value in exchange_rate_provider.items():
if not callable(value):
exchange_rate_provider_serializable[key] = value
exchange_rate_providers_serializable[ref] = exchange_rate_provider_serializable
async def fetch_fiat_exchange_rate(currency: str, provider: str):
replacements = {
"FROM": "BTC",
"from": "btc",
"TO": currency.upper(),
"to": currency.lower(),
}
api_url_or_none = exchange_rate_providers[provider]["api_url"]
if api_url_or_none is not None:
api_url = str(api_url_or_none)
for key in replacements.keys():
api_url = api_url.replace("{" + key + "}", replacements[key])
async with httpx.AsyncClient() as client:
r = await client.get(api_url)
r.raise_for_status()
data = r.json()
else:
data = {}
getter = exchange_rate_providers[provider]["getter"]
if not callable(getter):
return None
return float(getter(data, replacements))

View file

@ -1,166 +0,0 @@
{
"AED": "United Arab Emirates Dirham",
"AFN": "Afghan Afghani",
"ALL": "Albanian Lek",
"AMD": "Armenian Dram",
"ANG": "Netherlands Antillean Gulden",
"AOA": "Angolan Kwanza",
"ARS": "Argentine Peso",
"AUD": "Australian Dollar",
"AWG": "Aruban Florin",
"AZN": "Azerbaijani Manat",
"BAM": "Bosnia and Herzegovina Convertible Mark",
"BBD": "Barbadian Dollar",
"BDT": "Bangladeshi Taka",
"BGN": "Bulgarian Lev",
"BHD": "Bahraini Dinar",
"BIF": "Burundian Franc",
"BMD": "Bermudian Dollar",
"BND": "Brunei Dollar",
"BOB": "Bolivian Boliviano",
"BRL": "Brazilian Real",
"BSD": "Bahamian Dollar",
"BTN": "Bhutanese Ngultrum",
"BWP": "Botswana Pula",
"BYN": "Belarusian Ruble",
"BYR": "Belarusian Ruble",
"BZD": "Belize Dollar",
"CAD": "Canadian Dollar",
"CDF": "Congolese Franc",
"CHF": "Swiss Franc",
"CLF": "Unidad de Fomento",
"CLP": "Chilean Peso",
"CNH": "Chinese Renminbi Yuan Offshore",
"CNY": "Chinese Renminbi Yuan",
"COP": "Colombian Peso",
"CRC": "Costa Rican Colón",
"CUC": "Cuban Convertible Peso",
"CVE": "Cape Verdean Escudo",
"CZK": "Czech Koruna",
"DJF": "Djiboutian Franc",
"DKK": "Danish Krone",
"DOP": "Dominican Peso",
"DZD": "Algerian Dinar",
"EGP": "Egyptian Pound",
"ERN": "Eritrean Nakfa",
"ETB": "Ethiopian Birr",
"EUR": "Euro",
"FJD": "Fijian Dollar",
"FKP": "Falkland Pound",
"GBP": "British Pound",
"GEL": "Georgian Lari",
"GGP": "Guernsey Pound",
"GHS": "Ghanaian Cedi",
"GIP": "Gibraltar Pound",
"GMD": "Gambian Dalasi",
"GNF": "Guinean Franc",
"GTQ": "Guatemalan Quetzal",
"GYD": "Guyanese Dollar",
"HKD": "Hong Kong Dollar",
"HNL": "Honduran Lempira",
"HRK": "Croatian Kuna",
"HTG": "Haitian Gourde",
"HUF": "Hungarian Forint",
"IDR": "Indonesian Rupiah",
"ILS": "Israeli New Sheqel",
"IMP": "Isle of Man Pound",
"INR": "Indian Rupee",
"IQD": "Iraqi Dinar",
"ISK": "Icelandic Króna",
"JEP": "Jersey Pound",
"JMD": "Jamaican Dollar",
"JOD": "Jordanian Dinar",
"JPY": "Japanese Yen",
"KES": "Kenyan Shilling",
"KGS": "Kyrgyzstani Som",
"KHR": "Cambodian Riel",
"KMF": "Comorian Franc",
"KRW": "South Korean Won",
"KWD": "Kuwaiti Dinar",
"KYD": "Cayman Islands Dollar",
"KZT": "Kazakhstani Tenge",
"LAK": "Lao Kip",
"LBP": "Lebanese Pound",
"LKR": "Sri Lankan Rupee",
"LRD": "Liberian Dollar",
"LSL": "Lesotho Loti",
"LYD": "Libyan Dinar",
"MAD": "Moroccan Dirham",
"MDL": "Moldovan Leu",
"MGA": "Malagasy Ariary",
"MKD": "Macedonian Denar",
"MMK": "Myanmar Kyat",
"MNT": "Mongolian Tögrög",
"MOP": "Macanese Pataca",
"MRO": "Mauritanian Ouguiya",
"MUR": "Mauritian Rupee",
"MVR": "Maldivian Rufiyaa",
"MWK": "Malawian Kwacha",
"MXN": "Mexican Peso",
"MYR": "Malaysian Ringgit",
"MZN": "Mozambican Metical",
"NAD": "Namibian Dollar",
"NGN": "Nigerian Naira",
"NIO": "Nicaraguan Córdoba",
"NOK": "Norwegian Krone",
"NPR": "Nepalese Rupee",
"NZD": "New Zealand Dollar",
"OMR": "Omani Rial",
"PAB": "Panamanian Balboa",
"PEN": "Peruvian Sol",
"PGK": "Papua New Guinean Kina",
"PHP": "Philippine Peso",
"PKR": "Pakistani Rupee",
"PLN": "Polish Złoty",
"PYG": "Paraguayan Guaraní",
"QAR": "Qatari Riyal",
"RON": "Romanian Leu",
"RSD": "Serbian Dinar",
"RUB": "Russian Ruble",
"RWF": "Rwandan Franc",
"SAR": "Saudi Riyal",
"SBD": "Solomon Islands Dollar",
"SCR": "Seychellois Rupee",
"SEK": "Swedish Krona",
"SGD": "Singapore Dollar",
"SHP": "Saint Helenian Pound",
"SLL": "Sierra Leonean Leone",
"SOS": "Somali Shilling",
"SRD": "Surinamese Dollar",
"SSP": "South Sudanese Pound",
"STD": "São Tomé and Príncipe Dobra",
"SVC": "Salvadoran Colón",
"SZL": "Swazi Lilangeni",
"THB": "Thai Baht",
"TJS": "Tajikistani Somoni",
"TMT": "Turkmenistani Manat",
"TND": "Tunisian Dinar",
"TOP": "Tongan Paʻanga",
"TRY": "Turkish Lira",
"TTD": "Trinidad and Tobago Dollar",
"TWD": "New Taiwan Dollar",
"TZS": "Tanzanian Shilling",
"UAH": "Ukrainian Hryvnia",
"UGX": "Ugandan Shilling",
"USD": "US Dollar",
"UYU": "Uruguayan Peso",
"UZS": "Uzbekistan Som",
"VEF": "Venezuelan Bolívar",
"VES": "Venezuelan Bolívar Soberano",
"VND": "Vietnamese Đồng",
"VUV": "Vanuatu Vatu",
"WST": "Samoan Tala",
"XAF": "Central African Cfa Franc",
"XAG": "Silver (Troy Ounce)",
"XAU": "Gold (Troy Ounce)",
"XCD": "East Caribbean Dollar",
"XDR": "Special Drawing Rights",
"XOF": "West African Cfa Franc",
"XPD": "Palladium",
"XPF": "Cfp Franc",
"XPT": "Platinum",
"YER": "Yemeni Rial",
"ZAR": "South African Rand",
"ZMW": "Zambian Kwacha",
"ZWL": "Zimbabwean Dollar"
}

View file

@ -1,153 +0,0 @@
import base64
import hashlib
import hmac
from http import HTTPStatus
from typing import Dict
from urllib import parse
from fastapi import Request
def generate_bleskomat_lnurl_hash(secret: str):
m = hashlib.sha256()
m.update(f"{secret}".encode())
return m.hexdigest()
def generate_bleskomat_lnurl_signature(
payload: str, api_key_secret: str, api_key_encoding: str = "hex"
):
if api_key_encoding == "hex":
key = bytes.fromhex(api_key_secret)
elif api_key_encoding == "base64":
key = base64.b64decode(api_key_secret)
else:
key = bytes.fromhex(api_key_secret)
return hmac.new(key=key, msg=payload.encode(), digestmod=hashlib.sha256).hexdigest()
def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str):
# The secret is not randomly generated by the server.
# Instead it is the hash of the API key ID and signature concatenated together.
m = hashlib.sha256()
m.update(f"{api_key_id}-{signature}".encode())
return m.hexdigest()
def get_callback_url(req: Request):
return req.url_for("bleskomat.api_bleskomat_lnurl")
def is_supported_lnurl_subprotocol(tag: str) -> bool:
return tag == "withdrawRequest"
class LnurlHttpError(Exception):
def __init__(
self,
message: str = "",
http_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
):
self.message = message
self.http_status = http_status
super().__init__(self.message)
class LnurlValidationError(Exception):
pass
def prepare_lnurl_params(tag: str, query: dict) -> dict:
params: dict = {}
if not is_supported_lnurl_subprotocol(tag):
raise LnurlValidationError(f'Unsupported subprotocol: "{tag}"')
if tag == "withdrawRequest":
params["minWithdrawable"] = float(query["minWithdrawable"])
params["maxWithdrawable"] = float(query["maxWithdrawable"])
params["defaultDescription"] = query["defaultDescription"]
if not params["minWithdrawable"] > 0:
raise LnurlValidationError('"minWithdrawable" must be greater than zero')
if not params["maxWithdrawable"] >= params["minWithdrawable"]:
raise LnurlValidationError(
'"maxWithdrawable" must be greater than or equal to "minWithdrawable"'
)
return params
encode_uri_component_safe_chars = (
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!~*'()"
)
def query_to_signing_payload(query: Dict[str, str]) -> str:
# Sort the query by key, then stringify it to create the payload.
sorted_keys = sorted(query.keys(), key=str.lower)
payload = []
for key in sorted_keys:
if not key == "signature":
encoded_key = parse.quote(key, safe=encode_uri_component_safe_chars)
encoded_value = parse.quote(
query[key], safe=encode_uri_component_safe_chars
)
payload.append(f"{encoded_key}={encoded_value}")
return "&".join(payload)
unshorten_rules: dict[str, dict] = {
"query": {"n": "nonce", "s": "signature", "t": "tag"},
"tags": {
"c": "channelRequest",
"l": "login",
"p": "payRequest",
"w": "withdrawRequest",
},
"params": {
"channelRequest": {"pl": "localAmt", "pp": "pushAmt"},
"login": {},
"payRequest": {"pn": "minSendable", "px": "maxSendable", "pm": "metadata"},
"withdrawRequest": {
"pn": "minWithdrawable",
"px": "maxWithdrawable",
"pd": "defaultDescription",
},
},
}
def unshorten_lnurl_query(query: dict) -> Dict[str, str]:
new_query = {}
rules = unshorten_rules
if "tag" in query:
tag = query["tag"]
elif "t" in query:
tag = query["t"]
else:
raise LnurlValidationError('Missing required query parameter: "tag"')
# Unshorten tag:
if tag in rules["tags"]:
long_tag = rules["tags"][tag]
new_query["tag"] = long_tag
tag = long_tag
if tag not in rules["params"]:
raise LnurlValidationError(f'Unknown tag: "{tag}"')
for key in query:
if key in rules["params"][str(tag)]:
short_param_key = key
long_param_key = rules["params"][str(tag)][short_param_key]
if short_param_key in query:
new_query[long_param_key] = query[short_param_key]
else:
new_query[long_param_key] = query[long_param_key]
elif key in rules["query"]:
# Unshorten general keys:
short_key = key
long_key = rules["query"][short_key]
if long_key not in new_query:
if short_key in query:
new_query[long_key] = query[short_key]
else:
new_query[long_key] = query[str(long_key)]
else:
# Keep unknown key/value pairs unchanged:
new_query[key] = query[key]
return new_query

View file

@ -1,132 +0,0 @@
import json
import math
from http import HTTPStatus
from loguru import logger
from starlette.requests import Request
from . import bleskomat_ext
from .crud import (
create_bleskomat_lnurl,
get_bleskomat_by_api_key_id,
get_bleskomat_lnurl,
)
from .exchange_rates import fetch_fiat_exchange_rate
from .helpers import (
LnurlHttpError,
LnurlValidationError,
generate_bleskomat_lnurl_secret,
generate_bleskomat_lnurl_signature,
prepare_lnurl_params,
query_to_signing_payload,
unshorten_lnurl_query,
)
# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs.
@bleskomat_ext.get("/u", name="bleskomat.api_bleskomat_lnurl")
async def api_bleskomat_lnurl(req: Request):
try:
query = dict(req.query_params)
# Unshorten query if "s" is used instead of "signature".
if "s" in query:
query = unshorten_lnurl_query(query)
if "signature" in query:
# Signature provided.
# Use signature to verify that the URL was generated by an authorized device.
# Later validate parameters, auto-generate LNURL, reply with LNURL response object.
signature = query["signature"]
# The API key ID, nonce, and tag should be present in the query string.
for field in ["id", "nonce", "tag"]:
if field not in query:
raise LnurlHttpError(
f'Failed API key signature check: Missing "{field}"',
HTTPStatus.BAD_REQUEST,
)
# URL signing scheme is described here:
# https://github.com/chill117/lnurl-node#how-to-implement-url-signing-scheme
payload = query_to_signing_payload(query)
api_key_id = query["id"]
bleskomat = await get_bleskomat_by_api_key_id(api_key_id)
if not bleskomat:
raise LnurlHttpError("Unknown API key", HTTPStatus.BAD_REQUEST)
api_key_secret = bleskomat.api_key_secret
api_key_encoding = bleskomat.api_key_encoding
expected_signature = generate_bleskomat_lnurl_signature(
payload, api_key_secret, api_key_encoding
)
if signature != expected_signature:
raise LnurlHttpError("Invalid API key signature", HTTPStatus.FORBIDDEN)
# Signature is valid.
# In the case of signed URLs, the secret is deterministic based on the API key ID and signature.
secret = generate_bleskomat_lnurl_secret(api_key_id, signature)
lnurl = await get_bleskomat_lnurl(secret)
if not lnurl:
try:
tag = query["tag"]
params = prepare_lnurl_params(tag, query)
if "f" in query:
rate = await fetch_fiat_exchange_rate(
currency=query["f"],
provider=bleskomat.exchange_rate_provider,
)
# Convert fee (%) to decimal:
fee = float(bleskomat.fee) / 100
if tag == "withdrawRequest":
for key in ["minWithdrawable", "maxWithdrawable"]:
amount_sats = int(
math.floor((params[key] / rate) * 1e8)
)
fee_sats = int(math.floor(amount_sats * fee))
amount_sats_less_fee = amount_sats - fee_sats
# Convert to msats:
params[key] = int(amount_sats_less_fee * 1e3)
except LnurlValidationError as e:
raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST)
# Create a new LNURL using the query parameters provided in the signed URL.
json_params = json.JSONEncoder().encode(params)
lnurl = await create_bleskomat_lnurl(
bleskomat=bleskomat,
secret=secret,
tag=tag,
params=json_params,
uses=1,
)
# Reply with LNURL response object.
return lnurl.get_info_response_object(secret, req)
# No signature provided.
# Treat as "action" callback.
if "k1" not in query:
raise LnurlHttpError("Missing secret", HTTPStatus.BAD_REQUEST)
secret = query["k1"]
lnurl = await get_bleskomat_lnurl(secret)
if not lnurl:
raise LnurlHttpError("Invalid secret", HTTPStatus.BAD_REQUEST)
if not lnurl.has_uses_remaining():
raise LnurlHttpError(
"Maximum number of uses already reached", HTTPStatus.BAD_REQUEST
)
try:
await lnurl.execute_action(query)
except LnurlValidationError as e:
raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST)
except LnurlHttpError as e:
return {"status": "ERROR", "reason": str(e)}
except Exception as e:
logger.error(str(e))
return {"status": "ERROR", "reason": "Unexpected error"}
return {"status": "OK"}

View file

@ -1,37 +0,0 @@
async def m001_initial(db):
await db.execute(
"""
CREATE TABLE bleskomat.bleskomats (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
api_key_id TEXT NOT NULL,
api_key_secret TEXT NOT NULL,
api_key_encoding TEXT NOT NULL,
name TEXT NOT NULL,
fiat_currency TEXT NOT NULL,
exchange_rate_provider TEXT NOT NULL,
fee TEXT NOT NULL,
UNIQUE(api_key_id)
);
"""
)
await db.execute(
"""
CREATE TABLE bleskomat.bleskomat_lnurls (
id TEXT PRIMARY KEY,
bleskomat TEXT NOT NULL,
wallet TEXT NOT NULL,
hash TEXT NOT NULL,
tag TEXT NOT NULL,
params TEXT NOT NULL,
api_key_id TEXT NOT NULL,
initial_uses INTEGER DEFAULT 1,
remaining_uses INTEGER DEFAULT 0,
created_time INTEGER,
updated_time INTEGER,
UNIQUE(hash)
);
"""
)

View file

@ -1,142 +0,0 @@
import json
import time
from typing import Dict
from fastapi import Query, Request
from loguru import logger
from pydantic import BaseModel, validator
from lnbits import bolt11
from lnbits.core.services import PaymentFailure, pay_invoice
from . import db
from .exchange_rates import exchange_rate_providers, fiat_currencies
from .helpers import LnurlValidationError, get_callback_url
class CreateBleskomat(BaseModel):
name: str = Query(...)
fiat_currency: str = Query(...)
exchange_rate_provider: str = Query(...)
fee: str = Query(...)
@validator("fiat_currency")
def allowed_fiat_currencies(cls, v):
if v not in fiat_currencies.keys():
raise ValueError("Not allowed currency")
return v
@validator("exchange_rate_provider")
def allowed_providers(cls, v):
if v not in exchange_rate_providers.keys():
raise ValueError("Not allowed provider")
return v
@validator("fee")
def fee_type(cls, v):
if not isinstance(v, (str, float, int)):
raise ValueError("Fee type not allowed")
return v
class Bleskomat(BaseModel):
id: str
wallet: str
api_key_id: str
api_key_secret: str
api_key_encoding: str
name: str
fiat_currency: str
exchange_rate_provider: str
fee: str
class BleskomatLnurl(BaseModel):
id: str
bleskomat: str
wallet: str
hash: str
tag: str
params: str
api_key_id: str
initial_uses: int
remaining_uses: int
created_time: int
updated_time: int
def has_uses_remaining(self) -> bool:
# When initial uses is 0 then the LNURL has unlimited uses.
return self.initial_uses == 0 or self.remaining_uses > 0
def get_info_response_object(self, secret: str, req: Request) -> Dict[str, str]:
tag = self.tag
params = json.loads(self.params)
response = {"tag": tag}
if tag == "withdrawRequest":
for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]:
response[key] = params[key]
response["callback"] = get_callback_url(req)
response["k1"] = secret
return response
def validate_action(self, query) -> None:
tag = self.tag
params = json.loads(self.params)
# Perform tag-specific checks.
if tag == "withdrawRequest":
for field in ["pr"]:
if field not in query:
raise LnurlValidationError(f'Missing required parameter: "{field}"')
# Check the bolt11 invoice(s) provided.
pr = query["pr"]
if "," in pr:
raise LnurlValidationError("Multiple payment requests not supported")
try:
invoice = bolt11.decode(pr)
except ValueError:
raise LnurlValidationError(
'Invalid parameter ("pr"): Lightning payment request expected'
)
if invoice.amount_msat < params["minWithdrawable"]:
raise LnurlValidationError(
'Amount in invoice must be greater than or equal to "minWithdrawable"'
)
if invoice.amount_msat > params["maxWithdrawable"]:
raise LnurlValidationError(
'Amount in invoice must be less than or equal to "maxWithdrawable"'
)
else:
raise LnurlValidationError(f'Unknown subprotocol: "{tag}"')
async def execute_action(self, query):
self.validate_action(query)
used = False
async with db.connect() as conn:
if self.initial_uses > 0:
used = await self.use(conn)
if not used:
raise LnurlValidationError("Maximum number of uses already reached")
tag = self.tag
if tag == "withdrawRequest":
try:
await pay_invoice(
wallet_id=self.wallet, payment_request=query["pr"]
)
except (ValueError, PermissionError, PaymentFailure) as e:
raise LnurlValidationError("Failed to pay invoice: " + str(e))
except Exception as e:
logger.error(str(e))
raise LnurlValidationError("Unexpected error")
async def use(self, conn) -> bool:
now = int(time.time())
result = await conn.execute(
"""
UPDATE bleskomat.bleskomat_lnurls
SET remaining_uses = remaining_uses - 1, updated_time = ?
WHERE id = ?
AND remaining_uses > 0
""",
(now, self.id),
)
return result.rowcount > 0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View file

@ -1,216 +0,0 @@
/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */
Vue.component(VueQrcode.name, VueQrcode)
var mapBleskomat = function (obj) {
obj._data = _.clone(obj)
return obj
}
var defaultValues = {
name: 'My Bleskomat',
fiat_currency: 'EUR',
exchange_rate_provider: 'coinbase',
fee: '0.00'
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
checker: null,
bleskomats: [],
bleskomatsTable: {
columns: [
{
name: 'api_key_id',
align: 'left',
label: 'API Key ID',
field: 'api_key_id'
},
{
name: 'name',
align: 'left',
label: 'Name',
field: 'name'
},
{
name: 'fiat_currency',
align: 'left',
label: 'Fiat Currency',
field: 'fiat_currency'
},
{
name: 'exchange_rate_provider',
align: 'left',
label: 'Exchange Rate Provider',
field: 'exchange_rate_provider'
},
{
name: 'fee',
align: 'left',
label: 'Fee (%)',
field: 'fee'
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
fiatCurrencies: _.keys(window.bleskomat_vars.fiat_currencies),
exchangeRateProviders: _.keys(
window.bleskomat_vars.exchange_rate_providers
),
data: _.clone(defaultValues)
}
}
},
computed: {
sortedBleskomats: function () {
return this.bleskomats.sort(function (a, b) {
// Sort by API Key ID alphabetically.
var apiKeyId_A = a.api_key_id.toLowerCase()
var apiKeyId_B = b.api_key_id.toLowerCase()
return apiKeyId_A < apiKeyId_B ? -1 : apiKeyId_A > apiKeyId_B ? 1 : 0
})
}
},
methods: {
getBleskomats: function () {
var self = this
LNbits.api
.request(
'GET',
'/bleskomat/api/v1/bleskomats?all_wallets=true',
this.g.user.wallets[0].adminkey
)
.then(function (response) {
self.bleskomats = response.data.map(function (obj) {
return mapBleskomat(obj)
})
})
.catch(function (error) {
clearInterval(self.checker)
LNbits.utils.notifyApiError(error)
})
},
closeFormDialog: function () {
this.formDialog.data = _.clone(defaultValues)
},
exportConfigFile: function (bleskomatId) {
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
var fieldToKey = {
api_key_id: 'apiKey.id',
api_key_secret: 'apiKey.key',
api_key_encoding: 'apiKey.encoding',
fiat_currency: 'fiatCurrency'
}
var lines = _.chain(bleskomat)
.map(function (value, field) {
var key = fieldToKey[field] || null
return key ? [key, value].join('=') : null
})
.compact()
.value()
lines.push('callbackUrl=' + window.bleskomat_vars.callback_url)
lines.push('shorten=true')
var content = lines.join('\n')
var status = Quasar.utils.exportFile(
'bleskomat.conf',
content,
'text/plain'
)
if (status !== true) {
Quasar.plugins.Notify.create({
message: 'Browser denied file download...',
color: 'negative',
icon: null
})
}
},
openUpdateDialog: function (bleskomatId) {
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
this.formDialog.data = _.clone(bleskomat._data)
this.formDialog.show = true
},
sendFormData: function () {
var wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
var data = _.omit(this.formDialog.data, 'wallet')
if (data.id) {
this.updateBleskomat(wallet, data)
} else {
this.createBleskomat(wallet, data)
}
},
updateBleskomat: function (wallet, data) {
var self = this
LNbits.api
.request(
'PUT',
'/bleskomat/api/v1/bleskomat/' + data.id,
wallet.adminkey,
_.pick(data, 'name', 'fiat_currency', 'exchange_rate_provider', 'fee')
)
.then(function (response) {
self.bleskomats = _.reject(self.bleskomats, function (obj) {
return obj.id === data.id
})
self.bleskomats.push(mapBleskomat(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
createBleskomat: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/bleskomat/api/v1/bleskomat', wallet.adminkey, data)
.then(function (response) {
self.bleskomats.push(mapBleskomat(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteBleskomat: function (bleskomatId) {
var self = this
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
LNbits.utils
.confirmDialog(
'Are you sure you want to delete "' + bleskomat.name + '"?'
)
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/bleskomat/api/v1/bleskomat/' + bleskomatId,
_.findWhere(self.g.user.wallets, {id: bleskomat.wallet}).adminkey
)
.then(function (response) {
self.bleskomats = _.reject(self.bleskomats, function (obj) {
return obj.id === bleskomatId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
}
},
created: function () {
if (this.g.user.wallets.length) {
var getBleskomats = this.getBleskomats
getBleskomats()
this.checker = setInterval(function () {
getBleskomats()
}, 20000)
}
}
})

View file

@ -1,68 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Setup guide"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<p>
This extension allows you to connect a Bleskomat ATM to an lnbits
wallet. It will work with both the
<a class="text-secondary" href="https://github.com/samotari/bleskomat"
>open-source DIY Bleskomat ATM project</a
>
as well as the
<a class="text-secondary" href="https://www.bleskomat.com/"
>commercial Bleskomat ATM</a
>.
</p>
<h5 class="text-subtitle1 q-my-none">Connect Your Bleskomat ATM</h5>
<div>
<ol>
<li>Click the "Add Bleskomat" button on this page to begin.</li>
<li>
Choose a wallet. This will be the wallet that is used to pay
satoshis to your ATM customers.
</li>
<li>
Choose the fiat currency. This should match the fiat currency that
your ATM accepts.
</li>
<li>
Pick an exchange rate provider. This is the API that will be used to
query the fiat to satoshi exchange rate at the time your customer
attempts to withdraw their funds.
</li>
<li>Set your ATM's fee percentage.</li>
<li>Click the "Done" button.</li>
<li>
Find the new Bleskomat in the list and then click the export icon to
download a new configuration file for your ATM.
</li>
<li>
Copy the configuration file ("bleskomat.conf") to your ATM's SD
card.
</li>
<li>
Restart Your Bleskomat ATM. It should automatically reload the
configurations from the SD card.
</li>
</ol>
</div>
<h5 class="text-subtitle1 q-my-none">How does it work?</h5>
<p>
Since the Bleskomat ATMs are designed to be offline, a cryptographic
signing scheme is used to verify that the URL was generated by an
authorized device. When one of your customers inserts fiat money into
the device, a signed URL (lnurl-withdraw) is created and displayed as a
QR code. Your customer scans the QR code with their lnurl-supporting
mobile app, their mobile app communicates with the web API of lnbits to
verify the signature, the fiat currency amount is converted to sats, the
customer accepts the withdrawal, and finally lnbits will pay the
customer from your lnbits wallet.
</p>
</q-card-section>
</q-card>
<q-btn flat label="Swagger API" type="a" href="../docs#/Bleskomat"></q-btn>
</q-expansion-item>

View file

@ -1,180 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }}
<script>
{% if bleskomat_vars %}
window.bleskomat_vars = {{ bleskomat_vars | tojson | safe }}
{% endif %}
</script>
<script src="/bleskomat/static/js/index.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>Add Bleskomat</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Bleskomats</h5>
</div>
</div>
<q-table
dense
flat
:data="sortedBleskomats"
row-key="id"
:columns="bleskomatsTable.columns"
:pagination.sync="bleskomatsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
flat
dense
size="xs"
icon="file_download"
color="orange"
@click="exportConfigFile(props.row.id)"
>
<q-tooltip content-class="bg-accent"
>Export Configuration</q-tooltip
>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
>
<q-tooltip content-class="bg-accent">Edit</q-tooltip>
</q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteBleskomat(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip content-class="bg-accent">Delete</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Bleskomat extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "bleskomat/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.name"
type="text"
label="Name *"
></q-input>
<q-select
filled
dense
v-model="formDialog.data.fiat_currency"
:options="formDialog.fiatCurrencies"
label="Fiat Currency *"
>
</q-select>
<q-select
filled
dense
v-model="formDialog.data.exchange_rate_provider"
:options="formDialog.exchangeRateProviders"
label="Exchange Rate Provider *"
>
</q-select>
<q-input
filled
dense
v-model.number="formDialog.data.fee"
type="string"
:default="0.00"
label="Fee (%) *"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Bleskomat</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialog.data.wallet == null ||
formDialog.data.name == null ||
formDialog.data.fiat_currency == null ||
formDialog.data.exchange_rate_provider == null ||
formDialog.data.fee == null"
type="submit"
>Add Bleskomat</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %}

View file

@ -1,25 +0,0 @@
from fastapi import Depends, Request
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import bleskomat_ext, bleskomat_renderer
from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies
from .helpers import get_callback_url
templates = Jinja2Templates(directory="templates")
@bleskomat_ext.get("/", response_class=HTMLResponse)
async def index(req: Request, user: User = Depends(check_user_exists)):
bleskomat_vars = {
"callback_url": get_callback_url(req),
"exchange_rate_providers": exchange_rate_providers_serializable,
"fiat_currencies": fiat_currencies,
}
return bleskomat_renderer().TemplateResponse(
"bleskomat/index.html",
{"request": req, "user": user.dict(), "bleskomat_vars": bleskomat_vars},
)

View file

@ -1,100 +0,0 @@
from http import HTTPStatus
from fastapi import Depends, Query
from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, require_admin_key
from . import bleskomat_ext
from .crud import (
create_bleskomat,
delete_bleskomat,
get_bleskomat,
get_bleskomats,
update_bleskomat,
)
from .exchange_rates import fetch_fiat_exchange_rate
from .models import CreateBleskomat
@bleskomat_ext.get("/api/v1/bleskomats")
async def api_bleskomats(
wallet: WalletTypeInfo = Depends(require_admin_key),
all_wallets: bool = Query(False),
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else []
return [bleskomat.dict() for bleskomat in await get_bleskomats(wallet_ids)]
@bleskomat_ext.get("/api/v1/bleskomat/{bleskomat_id}")
async def api_bleskomat_retrieve(
bleskomat_id, wallet: WalletTypeInfo = Depends(require_admin_key)
):
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Bleskomat configuration not found.",
)
return bleskomat.dict()
@bleskomat_ext.post("/api/v1/bleskomat")
@bleskomat_ext.put("/api/v1/bleskomat/{bleskomat_id}")
async def api_bleskomat_create_or_update(
data: CreateBleskomat,
wallet: WalletTypeInfo = Depends(require_admin_key),
bleskomat_id=None,
):
fiat_currency = data.fiat_currency
exchange_rate_provider = data.exchange_rate_provider
try:
await fetch_fiat_exchange_rate(
currency=fiat_currency, provider=exchange_rate_provider
)
except Exception as e:
logger.error(e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"',
)
if bleskomat_id:
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Bleskomat configuration not found.",
)
bleskomat = await update_bleskomat(bleskomat_id, **data.dict())
else:
bleskomat = await create_bleskomat(wallet_id=wallet.wallet.id, data=data)
assert bleskomat
return bleskomat.dict()
@bleskomat_ext.delete("/api/v1/bleskomat/{bleskomat_id}")
async def api_bleskomat_delete(
bleskomat_id, wallet: WalletTypeInfo = Depends(require_admin_key)
):
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Bleskomat configuration not found.",
)
await delete_bleskomat(bleskomat_id)
return "", HTTPStatus.NO_CONTENT

View file

@ -1,42 +0,0 @@
# Swap on [Boltz](https://boltz.exchange)
providing **trustless** and **account-free** swap services since **2018.**
move **IN** and **OUT** of the **lightning network** and remain in control of your bitcoin, at all times.
* [Lightning Node](https://amboss.space/node/026165850492521f4ac8abd9bd8088123446d126f648ca35e60f88177dc149ceb2)
* [Documentation](https://docs.boltz.exchange/en/latest/)
* [Discord](https://discord.gg/d6EK85KK)
* [Twitter](https://twitter.com/Boltzhq)
* [FAQ](https://www.notion.so/Frequently-Asked-Questions-585328ae43944e2eba351050790d5eec) very cool!
# usage
This extension lets you create swaps, reverse swaps and in the case of failure refund your onchain funds.
## create normal swap (Onchain -> Lightning)
1. click on "Swap (IN)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to do your refund to if the swap fails after you already commited onchain funds.
---
![create swap](https://imgur.com/OyOh3Nm.png)
---
2. after you confirm your inputs, following dialog with the QR code for the onchain transaction, onchain- address and amount, will pop up.
---
![pay onchain tx](https://imgur.com/r2UhwCY.png)
---
3. after you pay this onchain address with the correct amount, boltz will see it and will pay your invoice and the sats will appear on your wallet.
if anything goes wrong when boltz is trying to pay your invoice, the swap will fail and you will need to refund your onchain funds after the timeout block height hit. (if boltz can pay the invoice, it wont be able to redeem your onchain funds either).
## create reverse swap (Lightning -> Onchain)
1. click on "Swap (OUT)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to receive your funds to. Instant settlement: means that LNbits will create the onchain claim transaction if it sees the boltz lockup transaction in the mempool, but it is not confirmed yet. it is advised to leave this checked because it is faster and the longer is takes to settle, the higher the chances are that the lightning invoice expires and the swap fails.
---
![reverse swap](https://imgur.com/UEAPpbs.png)
---
if this swap fails, boltz is doing the onchain refunding, because they have to commit onchain funds.
# refund locked onchain funds from a normal swap (Onchain -> Lightning)
if for some reason the normal swap fails and you already paid onchain, you can easily refund your btc.
this can happen if boltz is not able to pay your lightning invoice after you locked up your funds.
in case that happens, there is a info icon in the Swap (In) List which opens following dialog.
---
![refund](https://imgur.com/pN81ltf.png)
----
if the timeout block height is exceeded you can either press refund and lnbits will do the refunding to the address you specified when creating the swap. Or download the refundfile so you can manually refund your onchain directly on the boltz.exchange website.
if you think there is something wrong and/or you are unsure, you can ask for help either in LNbits telegram or in Boltz [Discord](https://discord.gg/d6EK85KK).
In a recent update we made *automated check*, every 15 minutes, to check if LNbits can refund your failed swap.

View file

@ -1,35 +0,0 @@
import asyncio
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_boltz")
boltz_ext: APIRouter = APIRouter(prefix="/boltz", tags=["boltz"])
def boltz_renderer():
return template_renderer(["lnbits/extensions/boltz/templates"])
boltz_static_files = [
{
"path": "/boltz/static",
"app": StaticFiles(directory="lnbits/extensions/boltz/static"),
"name": "boltz_static",
}
]
from .tasks import check_for_pending_swaps, wait_for_paid_invoices
from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403
def boltz_start():
loop = asyncio.get_event_loop()
loop.create_task(check_for_pending_swaps())
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View file

@ -1,6 +0,0 @@
{
"name": "Boltz",
"short_description": "Perform onchain/offchain swaps",
"tile": "/boltz/static/image/boltz.png",
"contributors": ["dni"]
}

View file

@ -1,284 +0,0 @@
import time
from typing import List, Optional, Union
from boltz_client.boltz import BoltzReverseSwapResponse, BoltzSwapResponse
from loguru import logger
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import (
AutoReverseSubmarineSwap,
CreateAutoReverseSubmarineSwap,
CreateReverseSubmarineSwap,
CreateSubmarineSwap,
ReverseSubmarineSwap,
SubmarineSwap,
)
async def get_submarine_swaps(wallet_ids: Union[str, List[str]]) -> List[SubmarineSwap]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltz.submarineswap WHERE wallet IN ({q}) order by time DESC",
(*wallet_ids,),
)
return [SubmarineSwap(**row) for row in rows]
async def get_all_pending_submarine_swaps() -> List[SubmarineSwap]:
rows = await db.fetchall(
"SELECT * FROM boltz.submarineswap WHERE status='pending' order by time DESC",
)
return [SubmarineSwap(**row) for row in rows]
async def get_submarine_swap(swap_id) -> Optional[SubmarineSwap]:
row = await db.fetchone(
"SELECT * FROM boltz.submarineswap WHERE id = ?", (swap_id,)
)
return SubmarineSwap(**row) if row else None
async def create_submarine_swap(
data: CreateSubmarineSwap,
swap: BoltzSwapResponse,
swap_id: str,
refund_privkey_wif: str,
payment_hash: str,
) -> Optional[SubmarineSwap]:
await db.execute(
"""
INSERT INTO boltz.submarineswap (
id,
wallet,
payment_hash,
status,
boltz_id,
refund_privkey,
refund_address,
expected_amount,
timeout_block_height,
address,
bip21,
redeem_script,
amount
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
swap_id,
data.wallet,
payment_hash,
"pending",
swap.id,
refund_privkey_wif,
data.refund_address,
swap.expectedAmount,
swap.timeoutBlockHeight,
swap.address,
swap.bip21,
swap.redeemScript,
data.amount,
),
)
return await get_submarine_swap(swap_id)
async def get_reverse_submarine_swaps(
wallet_ids: Union[str, List[str]]
) -> List[ReverseSubmarineSwap]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltz.reverse_submarineswap WHERE wallet IN ({q}) order by time DESC",
(*wallet_ids,),
)
return [ReverseSubmarineSwap(**row) for row in rows]
async def get_all_pending_reverse_submarine_swaps() -> List[ReverseSubmarineSwap]:
rows = await db.fetchall(
"SELECT * FROM boltz.reverse_submarineswap WHERE status='pending' order by time DESC"
)
return [ReverseSubmarineSwap(**row) for row in rows]
async def get_reverse_submarine_swap(swap_id) -> Optional[ReverseSubmarineSwap]:
row = await db.fetchone(
"SELECT * FROM boltz.reverse_submarineswap WHERE id = ?", (swap_id,)
)
return ReverseSubmarineSwap(**row) if row else None
async def create_reverse_submarine_swap(
data: CreateReverseSubmarineSwap,
claim_privkey_wif: str,
preimage_hex: str,
swap: BoltzReverseSwapResponse,
) -> ReverseSubmarineSwap:
swap_id = urlsafe_short_hash()
reverse_swap = ReverseSubmarineSwap(
id=swap_id,
wallet=data.wallet,
status="pending",
boltz_id=swap.id,
instant_settlement=data.instant_settlement,
preimage=preimage_hex,
claim_privkey=claim_privkey_wif,
lockup_address=swap.lockupAddress,
invoice=swap.invoice,
onchain_amount=swap.onchainAmount,
onchain_address=data.onchain_address,
timeout_block_height=swap.timeoutBlockHeight,
redeem_script=swap.redeemScript,
amount=data.amount,
time=int(time.time()),
)
await db.execute(
"""
INSERT INTO boltz.reverse_submarineswap (
id,
wallet,
status,
boltz_id,
instant_settlement,
preimage,
claim_privkey,
lockup_address,
invoice,
onchain_amount,
onchain_address,
timeout_block_height,
redeem_script,
amount
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
reverse_swap.id,
reverse_swap.wallet,
reverse_swap.status,
reverse_swap.boltz_id,
reverse_swap.instant_settlement,
reverse_swap.preimage,
reverse_swap.claim_privkey,
reverse_swap.lockup_address,
reverse_swap.invoice,
reverse_swap.onchain_amount,
reverse_swap.onchain_address,
reverse_swap.timeout_block_height,
reverse_swap.redeem_script,
reverse_swap.amount,
),
)
return reverse_swap
async def get_auto_reverse_submarine_swaps(
wallet_ids: List[str],
) -> List[AutoReverseSubmarineSwap]:
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltz.auto_reverse_submarineswap WHERE wallet IN ({q}) order by time DESC",
(*wallet_ids,),
)
return [AutoReverseSubmarineSwap(**row) for row in rows]
async def get_auto_reverse_submarine_swap(
swap_id,
) -> Optional[AutoReverseSubmarineSwap]:
row = await db.fetchone(
"SELECT * FROM boltz.auto_reverse_submarineswap WHERE id = ?", (swap_id,)
)
return AutoReverseSubmarineSwap(**row) if row else None
async def get_auto_reverse_submarine_swap_by_wallet(
wallet_id,
) -> Optional[AutoReverseSubmarineSwap]:
row = await db.fetchone(
"SELECT * FROM boltz.auto_reverse_submarineswap WHERE wallet = ?", (wallet_id,)
)
return AutoReverseSubmarineSwap(**row) if row else None
async def create_auto_reverse_submarine_swap(
swap: CreateAutoReverseSubmarineSwap,
) -> Optional[AutoReverseSubmarineSwap]:
swap_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO boltz.auto_reverse_submarineswap (
id,
wallet,
onchain_address,
instant_settlement,
balance,
amount
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
swap_id,
swap.wallet,
swap.onchain_address,
swap.instant_settlement,
swap.balance,
swap.amount,
),
)
return await get_auto_reverse_submarine_swap(swap_id)
async def delete_auto_reverse_submarine_swap(swap_id):
await db.execute(
"DELETE FROM boltz.auto_reverse_submarineswap WHERE id = ?", (swap_id,)
)
async def update_swap_status(swap_id: str, status: str):
swap = await get_submarine_swap(swap_id)
if swap:
await db.execute(
"UPDATE boltz.submarineswap SET status='"
+ status
+ "' WHERE id='"
+ swap.id
+ "'"
)
logger.info(
f"Boltz - swap status change: {status}. boltz_id: {swap.boltz_id}, wallet: {swap.wallet}"
)
return swap
reverse_swap = await get_reverse_submarine_swap(swap_id)
if reverse_swap:
await db.execute(
"UPDATE boltz.reverse_submarineswap SET status='"
+ status
+ "' WHERE id='"
+ reverse_swap.id
+ "'"
)
logger.info(
f"Boltz - reverse swap status change: {status}. boltz_id: {reverse_swap.boltz_id}, wallet: {reverse_swap.wallet}"
)
return reverse_swap
return None

View file

@ -1,64 +0,0 @@
async def m001_initial(db):
await db.execute(
f"""
CREATE TABLE boltz.submarineswap (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
payment_hash TEXT NOT NULL,
amount {db.big_int} NOT NULL,
status TEXT NOT NULL,
boltz_id TEXT NOT NULL,
refund_address TEXT NOT NULL,
refund_privkey TEXT NOT NULL,
expected_amount {db.big_int} NOT NULL,
timeout_block_height INT NOT NULL,
address TEXT NOT NULL,
bip21 TEXT NOT NULL,
redeem_script TEXT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)
await db.execute(
f"""
CREATE TABLE boltz.reverse_submarineswap (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
onchain_address TEXT NOT NULL,
amount {db.big_int} NOT NULL,
instant_settlement BOOLEAN NOT NULL,
status TEXT NOT NULL,
boltz_id TEXT NOT NULL,
timeout_block_height INT NOT NULL,
redeem_script TEXT NOT NULL,
preimage TEXT NOT NULL,
claim_privkey TEXT NOT NULL,
lockup_address TEXT NOT NULL,
invoice TEXT NOT NULL,
onchain_amount {db.big_int} NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)
async def m002_auto_swaps(db):
await db.execute(
"""
CREATE TABLE boltz.auto_reverse_submarineswap (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
onchain_address TEXT NOT NULL,
amount INT NOT NULL,
balance INT NOT NULL,
instant_settlement BOOLEAN NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)

View file

@ -1,68 +0,0 @@
from fastapi import Query
from pydantic import BaseModel
class SubmarineSwap(BaseModel):
id: str
wallet: str
amount: int
payment_hash: str
time: int
status: str
refund_privkey: str
refund_address: str
boltz_id: str
expected_amount: int
timeout_block_height: int
address: str
bip21: str
redeem_script: str
class CreateSubmarineSwap(BaseModel):
wallet: str = Query(...)
refund_address: str = Query(...)
amount: int = Query(...)
class ReverseSubmarineSwap(BaseModel):
id: str
wallet: str
amount: int
onchain_address: str
instant_settlement: bool
time: int
status: str
boltz_id: str
preimage: str
claim_privkey: str
lockup_address: str
invoice: str
onchain_amount: int
timeout_block_height: int
redeem_script: str
class CreateReverseSubmarineSwap(BaseModel):
wallet: str = Query(...)
amount: int = Query(...)
instant_settlement: bool = Query(...)
onchain_address: str = Query(...)
class AutoReverseSubmarineSwap(BaseModel):
id: str
wallet: str
amount: int
balance: int
onchain_address: str
instant_settlement: bool
time: int
class CreateAutoReverseSubmarineSwap(BaseModel):
wallet: str = Query(...)
amount: int = Query(...)
balance: int = Query(0)
instant_settlement: bool = Query(...)
onchain_address: str = Query(...)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

View file

@ -1,180 +0,0 @@
import asyncio
from boltz_client.boltz import BoltzNotFoundException, BoltzSwapStatusException
from boltz_client.mempool import MempoolBlockHeightException
from loguru import logger
from lnbits.core.crud import get_wallet
from lnbits.core.models import Payment
from lnbits.core.services import check_transaction_status, fee_reserve
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import (
create_reverse_submarine_swap,
get_all_pending_reverse_submarine_swaps,
get_all_pending_submarine_swaps,
get_auto_reverse_submarine_swap_by_wallet,
get_submarine_swap,
update_swap_status,
)
from .models import CreateReverseSubmarineSwap, ReverseSubmarineSwap, SubmarineSwap
from .utils import create_boltz_client, execute_reverse_swap
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name())
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
await check_for_auto_swap(payment)
if payment.extra.get("tag") != "boltz":
# not a boltz invoice
return
await payment.set_pending(False)
if payment.extra:
swap_id = payment.extra.get("swap_id")
if swap_id:
swap = await get_submarine_swap(swap_id)
if swap:
await update_swap_status(swap_id, "complete")
async def check_for_auto_swap(payment: Payment) -> None:
auto_swap = await get_auto_reverse_submarine_swap_by_wallet(payment.wallet_id)
if auto_swap:
wallet = await get_wallet(payment.wallet_id)
if wallet:
reserve = fee_reserve(wallet.balance_msat) / 1000
balance = wallet.balance_msat / 1000
amount = balance - auto_swap.balance - reserve
if amount >= auto_swap.amount:
client = create_boltz_client()
claim_privkey_wif, preimage_hex, swap = client.create_reverse_swap(
amount=int(amount)
)
new_swap = await create_reverse_submarine_swap(
CreateReverseSubmarineSwap(
wallet=auto_swap.wallet,
amount=int(amount),
instant_settlement=auto_swap.instant_settlement,
onchain_address=auto_swap.onchain_address,
),
claim_privkey_wif,
preimage_hex,
swap,
)
await execute_reverse_swap(client, new_swap)
logger.info(
f"Boltz: auto reverse swap created with amount: {amount}, boltz_id: {new_swap.boltz_id}"
)
"""
testcases for boltz startup
A. normal swaps
1. test: create -> kill -> start -> startup invoice listeners -> pay onchain funds -> should complete
2. test: create -> kill -> pay onchain funds -> mine block -> start -> startup check -> should complete
3. test: create -> kill -> mine blocks and hit timeout -> start -> should go timeout/failed
4. test: create -> kill -> pay to less onchain funds -> mine blocks hit timeout -> start lnbits -> should be refunded
B. reverse swaps
1. test: create instant -> kill -> boltz does lockup -> not confirmed -> start lnbits -> should claim/complete
2. test: create -> kill -> boltz does lockup -> not confirmed -> start lnbits -> mine blocks -> should claim/complete
3. test: create -> kill -> boltz does lockup -> confirmed -> start lnbits -> should claim/complete
"""
async def check_for_pending_swaps():
try:
swaps = await get_all_pending_submarine_swaps()
reverse_swaps = await get_all_pending_reverse_submarine_swaps()
if len(swaps) > 0 or len(reverse_swaps) > 0:
logger.debug("Boltz - startup swap check")
except:
logger.error(
"Boltz - startup swap check, database is not created yet, do nothing"
)
return
client = create_boltz_client()
if len(swaps) > 0:
logger.debug(f"Boltz - {len(swaps)} pending swaps")
for swap in swaps:
await check_swap(swap, client)
if len(reverse_swaps) > 0:
logger.debug(f"Boltz - {len(reverse_swaps)} pending reverse swaps")
for reverse_swap in reverse_swaps:
await check_reverse_swap(reverse_swap, client)
async def check_swap(swap: SubmarineSwap, client):
try:
payment_status = await check_transaction_status(swap.wallet, swap.payment_hash)
if payment_status.paid:
logger.debug(f"Boltz - swap: {swap.boltz_id} got paid while offline.")
await update_swap_status(swap.id, "complete")
else:
try:
_ = client.swap_status(swap.id)
except:
txs = client.mempool.get_txs_from_address(swap.address)
if len(txs) == 0:
await update_swap_status(swap.id, "timeout")
else:
await client.refund_swap(
privkey_wif=swap.refund_privkey,
lockup_address=swap.address,
receive_address=swap.refund_address,
redeem_script_hex=swap.redeem_script,
timeout_block_height=swap.timeout_block_height,
)
await update_swap_status(swap.id, "refunded")
except BoltzNotFoundException:
logger.debug(f"Boltz - swap: {swap.boltz_id} does not exist.")
await update_swap_status(swap.id, "failed")
except MempoolBlockHeightException:
logger.debug(
f"Boltz - tried to refund swap: {swap.id}, but has not reached the timeout."
)
except Exception as exc:
logger.error(f"Boltz - unhandled exception, swap: {swap.id} - {str(exc)}")
async def check_reverse_swap(reverse_swap: ReverseSubmarineSwap, client):
try:
_ = client.swap_status(reverse_swap.boltz_id)
await client.claim_reverse_swap(
lockup_address=reverse_swap.lockup_address,
receive_address=reverse_swap.onchain_address,
privkey_wif=reverse_swap.claim_privkey,
preimage_hex=reverse_swap.preimage,
redeem_script_hex=reverse_swap.redeem_script,
zeroconf=reverse_swap.instant_settlement,
)
await update_swap_status(reverse_swap.id, "complete")
except BoltzSwapStatusException as exc:
logger.debug(f"Boltz - swap_status: {str(exc)}")
await update_swap_status(reverse_swap.id, "failed")
# should only happen while development when regtest is reset
except BoltzNotFoundException:
logger.debug(f"Boltz - reverse swap: {reverse_swap.boltz_id} does not exist.")
await update_swap_status(reverse_swap.id, "failed")
except Exception as exc:
logger.error(
f"Boltz - unhandled exception, reverse swap: {reverse_swap.id} - {str(exc)}"
)

View file

@ -1,109 +0,0 @@
<q-card>
<q-card-section>
<img src="https://boltz.exchange/static/media/Shape.6c1a92b3.svg" alt="" />
<img
src="https://boltz.exchange/static/media/Boltz.02fb7acb.svg"
style="padding: 5px 9px"
alt=""
/>
<p><b>NON CUSTODIAL atomic swap service</b></p>
<h5 class="text-subtitle1 q-my-none">
Providing trustless and account-free swap services since 2018. Move IN and
OUT of the lightning network and remain in control of your bitcoin, at all
time.
</h5>
<p>
Link:
<a target="_blank" href="https://boltz.exchange"
>https://boltz.exchange
</a>
<br />
README:
<a
target="_blank"
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/boltz"
>read more</a
>
</p>
<p>
<small
>Extension created by,
<a target="_blank" href="https://github.com/dni">dni</a></small
>
</p>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<h3 class="text-subtitle1 q-my-none">
<b>Fee Information</b>
</h3>
<span>
{% raw %} Every swap consists of 2 onchain transactions, lockup and claim
/ refund, routing fees and a Boltz fee of
<b>{{ boltzConfig.fee_percentage }}%</b>. {% endraw %}
</span>
</q-card-section>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Fee example: Lightning -> Onchain"
:content-inset-level="0.5"
>
<q-card-section>
{% raw %} You want to swap out {{ boltzExample.amount }} sats, Lightning
to Onchain:
<ul style="padding-left: 12px">
<li>Onchain lockup tx fee: ~{{ boltzExample.onchain_boltz }} sats</li>
<li>
Onchain claim tx fee: {{ boltzExample.onchain_lnbits }} sats
(hardcoded)
</li>
<li>Routing fees (paid by you): unknown</li>
<li>
Boltz fees: {{ boltzExample.boltz_fee }} sats ({{
boltzConfig.fee_percentage }}%)
</li>
<li>
Fees total: {{ boltzExample.reverse_fee_total }} sats + routing fees
</li>
<li>You receive: {{ boltzExample.reverse_receive }} sats</li>
</ul>
<p>
onchain_amount_received = amount - (amount * boltz_fee / 100) -
lockup_fee - claim_fee
</p>
{% endraw %}
</q-card-section>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Fee example: Onchain -> Lightning"
:content-inset-level="0.5"
>
<q-card-section>
{% raw %} You want to swap in {{ boltzExample.amount }} sats, Onchain to
Lightning:
<ul style="padding-left: 12px">
<li>Onchain lockup tx fee: whatever you choose when paying</li>
<li>Onchain claim tx fee: ~{{ boltzExample.onchain_boltz }} sats</li>
<li>Routing fees (paid by boltz): unknown</li>
<li>
Boltz fees: {{ boltzExample.boltz_fee }} sats ({{
boltzConfig.fee_percentage }}%)
</li>
<li>
Fees total: {{ boltzExample.normal_fee_total }} sats + lockup_fee
</li>
<li>
You pay onchain: {{ boltzExample.normal_expected_amount }} sats +
lockup_fee
</li>
<li>You receive lightning: {{ boltzExample.amount }} sats</li>
</ul>
<p>onchain_payment = amount + (amount * boltz_fee / 100) + claim_fee</p>
{% endraw %}
</q-card-section>
</q-expansion-item>
</q-card>

View file

@ -1,83 +0,0 @@
<q-dialog v-model="autoReverseSubmarineSwapDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendAutoReverseSubmarineSwapFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="autoReverseSubmarineSwapDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
:disable="autoReverseSubmarineSwapDialog.data.id ? true : false"
>
</q-select>
<q-input
filled
dense
emit-value
label="Balance to kept + fee_reserve"
v-model="autoReverseSubmarineSwapDialog.data.balance"
type="number"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
mininum balance kept in wallet after a swap + the fee_reserve
</q-tooltip>
</q-input>
<q-input
filled
dense
emit-value
:label="amountLabel()"
v-model.trim="autoReverseSubmarineSwapDialog.data.amount"
type="number"
></q-input>
<div class="row">
<div class="col">
<q-checkbox
v-model="autoReverseSubmarineSwapDialog.data.instant_settlement"
value="false"
label="Instant settlement"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
Create Onchain TX when transaction is in mempool, but not
confirmed yet.
</q-tooltip>
</q-checkbox>
</div>
</div>
<q-input
filled
dense
emit-value
v-model.trim="autoReverseSubmarineSwapDialog.data.onchain_address"
type="string"
label="Onchain address to receive funds"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="autoReverseSubmarineSwapDialog.data.id"
unelevated
color="primary"
type="submit"
label="Update Swap"
></q-btn>
<q-btn
v-else
unelevated
color="primary"
:disable="disableAutoReverseSubmarineSwapDialog()"
type="submit"
label="Create Auto Reverse Swap (Out)"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
@click="resetAutoReverseSubmarineSwapDialog"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>

View file

@ -1,54 +0,0 @@
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Auto Lightning -> Onchain</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportAutoReverseSubmarineSwapCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="autoReverseSubmarineSwaps"
row-key="id"
:columns="autoReverseSubmarineSwapTable.columns"
:pagination.sync="autoReverseSubmarineSwapTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td>
<q-btn
unelevated
dense
size="xs"
icon="delete"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="deleteAutoReverseSwap(props.row.id)"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>delete the automatic reverse swap</q-tooltip
>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>

View file

@ -1,35 +0,0 @@
<q-card>
<q-card-section>
<q-btn
label="Onchain -> Lightning"
unelevated
color="primary"
@click="submarineSwapDialog.show = true"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
Send onchain funds offchain (BTC -> LN)
</q-tooltip>
</q-btn>
<q-btn
label="Lightning -> Onchain"
unelevated
color="primary"
@click="reverseSubmarineSwapDialog.show = true"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
Send offchain funds to onchain address (LN -> BTC)
</q-tooltip>
</q-btn>
<q-btn
label="Auto (Lightning -> Onchain)"
unelevated
color="primary"
@click="autoReverseSubmarineSwapDialog.show = true"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
Automatically send offchain funds to onchain address (LN -> BTC) with a
predefined threshold
</q-tooltip>
</q-btn>
</q-card-section>
</q-card>

View file

@ -1,113 +0,0 @@
<q-dialog v-model="checkSwapDialog.show" maximized position="top">
<q-card v-if="checkSwapDialog.data" class="q-pa-lg lnbits__dialog-card">
<h5>pending swaps</h5>
<q-table
dense
flat
:data="checkSwapDialog.data.swaps"
row-key="id"
:columns="allStatusTable.columns"
:rows-per-page-options="[0]"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td style="width: 10%">
<q-btn
unelevated
dense
size="xs"
icon="cached"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="refundSwap(props.row.swap_id)"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>refund swap</q-tooltip
>
</q-btn>
<q-btn
unelevated
dense
size="xs"
icon="download"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="downloadRefundFile(props.row.swap_id)"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>dowload refund file</q-tooltip
>
</q-btn>
<q-btn
unelevated
dense
size="xs"
icon="flip_to_front"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openMempool(props.row.swap_id)"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>open tx on mempool.space</q-tooltip
>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
<h5>pending reverse swaps</h5>
<q-table
dense
flat
:data="checkSwapDialog.data.reverse_swaps"
row-key="id"
:columns="allStatusTable.columns"
:rows-per-page-options="[0]"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td style="width: 10%">
<q-btn
unelevated
dense
size="xs"
icon="flip_to_front"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openMempool(props.row.swap_id)"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>open tx on mempool.space</q-tooltip
>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
<div class="row q-mt-lg q-gutter-sm">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>

View file

@ -1,31 +0,0 @@
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="qrCodeDialog.data.bip21"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<div>
{% raw %}
<b>Bitcoin On-Chain TX</b><br />
<b>Expected amount (sats): </b> {{ qrCodeDialog.data.expected_amount }}
<br />
<b>Expected amount (btc): </b> {{ qrCodeDialog.data.expected_amount_btc }}
<br />
<b>Onchain Address: </b> {{ qrCodeDialog.data.address }} <br />
{% endraw %}
</div>
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.address, 'Onchain address copied to clipboard!')"
class="q-ml-sm"
>Copy On-Chain Address</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>

View file

@ -1,72 +0,0 @@
<q-dialog v-model="reverseSubmarineSwapDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendReverseSubmarineSwapFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="reverseSubmarineSwapDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
:disable="reverseSubmarineSwapDialog.data.id ? true : false"
>
</q-select>
<q-input
filled
dense
emit-value
:label="amountLabel()"
v-model.trim="reverseSubmarineSwapDialog.data.amount"
type="number"
></q-input>
<div class="row">
<div class="col">
<q-checkbox
v-model="reverseSubmarineSwapDialog.data.instant_settlement"
value="false"
label="Instant settlement"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
Create Onchain TX when transaction is in mempool, but not
confirmed yet.
</q-tooltip>
</q-checkbox>
</div>
</div>
<q-input
filled
dense
emit-value
v-model.trim="reverseSubmarineSwapDialog.data.onchain_address"
type="string"
label="Onchain address to receive funds"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="reverseSubmarineSwapDialog.data.id"
unelevated
color="primary"
type="submit"
label="Update Swap"
></q-btn>
<q-btn
v-else
unelevated
color="primary"
:disable="disableReverseSubmarineSwapDialog()"
type="submit"
label="Create Reverse Swap (OUT)"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
@click="resetReverseSubmarineSwapDialog"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>

View file

@ -1,66 +0,0 @@
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Lightning -> Onchain</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportReverseSubmarineSwapCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="reverseSubmarineSwaps"
row-key="id"
:columns="reverseSubmarineSwapTable.columns"
:pagination.sync="reverseSubmarineSwapTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td style="width: 10%">
<q-btn
unelevated
dense
size="xs"
icon="info"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openStatusDialog(props.row.id, true)"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>open swap status info</q-tooltip
>
</q-btn>
<q-btn
unelevated
dense
size="xs"
icon="flip_to_front"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openMempool(props.row.id)"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>open tx on mempool.space</q-tooltip
>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>

View file

@ -1,29 +0,0 @@
<q-dialog v-model="statusDialog.show" position="top">
<q-card v-if="statusDialog.data" class="q-pa-lg lnbits__dialog-card">
<div>
{% raw %}
<b>Status: </b> {{ statusDialog.data.status }} <br />
<br />
{% endraw %}
</div>
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
color="grey"
@click="refundSwap(statusDialog.data.swap_id)"
v-if="!statusDialog.data.reverse"
class="q-ml-sm"
>Refund
</q-btn>
<q-btn
outline
color="grey"
@click="downloadRefundFile(statusDialog.data.swap_id)"
v-if="!statusDialog.data.reverse"
class="q-ml-sm"
>Download refundfile</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>

View file

@ -1,58 +0,0 @@
<q-dialog v-model="submarineSwapDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendSubmarineSwapFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="submarineSwapDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
:disable="submarineSwapDialog.data.id ? true : false"
>
</q-select>
<q-input
filled
dense
emit-value
v-model.trim="submarineSwapDialog.data.amount"
:label="amountLabel()"
type="number"
></q-input>
<q-input
filled
dense
emit-value
v-model.trim="submarineSwapDialog.data.refund_address"
type="string"
label="Onchain address to receive funds if swap fails"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="submarineSwapDialog.data.id"
unelevated
color="primary"
type="submit"
label="Update Swap"
></q-btn>
<q-btn
v-else
unelevated
color="primary"
:disable="disableSubmarineSwapDialog()"
type="submit"
label="Create Swap (IN)"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
@click="resetSubmarineSwapDialog"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>

View file

@ -1,78 +0,0 @@
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Onchain -> Lightning</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportSubmarineSwapCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="submarineSwaps"
row-key="id"
:columns="submarineSwapTable.columns"
:pagination.sync="submarineSwapTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td style="width: 10%">
<q-btn
unelevated
dense
size="xs"
icon="visibility"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.id)"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>open swap onchain details</q-tooltip
>
</q-btn>
<q-btn
unelevated
dense
size="xs"
icon="info"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openStatusDialog(props.row.id)"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>open swap status info</q-tooltip
>
</q-btn>
<q-btn
unelevated
dense
size="xs"
icon="flip_to_front"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openMempool(props.row.id)"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>open tx on mempool.space</q-tooltip
>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>

View file

@ -1,621 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 q-gutter-y-md">
{% include "boltz/_buttons.html" %} {% include
"boltz/_submarineSwapList.html" %} {% include
"boltz/_reverseSubmarineSwapList.html" %} {% include
"boltz/_autoReverseSwapList.html" %}
</div>
<div class="col-12 col-md-4 q-gutter-y-md">
{% include "boltz/_api_docs.html" %}
</div>
{% include "boltz/_submarineSwapDialog.html" %} {% include
"boltz/_reverseSubmarineSwapDialog.html" %} {% include
"boltz/_autoReverseSwapDialog.html" %} {% include "boltz/_qrDialog.html" %} {%
include "boltz/_statusDialog.html" %}
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
mempool: '',
boltzConfig: {},
submarineSwaps: [],
reverseSubmarineSwaps: [],
autoReverseSubmarineSwaps: [],
statuses: [],
submarineSwapDialog: {
show: false,
data: {}
},
reverseSubmarineSwapDialog: {
show: false,
data: {
instant_settlement: true
}
},
autoReverseSubmarineSwapDialog: {
show: false,
data: {
balance: 100,
instant_settlement: true
}
},
qrCodeDialog: {
show: false,
data: {}
},
statusDialog: {
show: false,
data: {}
},
allStatusTable: {
columns: [
{
name: 'swap_id',
align: 'left',
label: 'Swap ID',
field: 'swap_id'
},
{
name: 'status',
align: 'left',
label: 'Status',
field: 'message'
},
{
name: 'boltz',
align: 'left',
label: 'Boltz',
field: 'boltz'
},
{
name: 'mempool',
align: 'left',
label: 'Mempool',
field: 'mempool'
},
{
name: 'timeout_block_height',
align: 'left',
label: 'Timeout block height',
field: 'timeout_block_height'
}
],
pagination: {
rowsPerPage: 10
}
},
autoReverseSubmarineSwapTable: {
columns: [
{
name: 'time',
align: 'left',
label: 'Time',
field: 'time',
sortable: true,
format: function (val, row) {
return new Date(val * 1000).toUTCString()
}
},
{
name: 'wallet',
align: 'left',
label: 'Wallet',
field: data => {
let wallet = _.findWhere(this.g.user.wallets, {
id: data.wallet
})
if (wallet) {
return wallet.name
}
}
},
{
name: 'balance',
align: 'left',
label: 'Balance',
field: 'balance'
},
{
name: 'amount',
align: 'left',
label: 'Amount',
field: 'amount'
},
{
name: 'onchain_address',
align: 'left',
label: 'Onchain address',
field: 'onchain_address'
}
],
pagination: {
rowsPerPage: 10
}
},
reverseSubmarineSwapTable: {
columns: [
{
name: 'time',
align: 'left',
label: 'Time',
field: 'time',
sortable: true,
format: function (val, row) {
return new Date(val * 1000).toUTCString()
}
},
{
name: 'wallet',
align: 'left',
label: 'Wallet',
field: data => {
let wallet = _.findWhere(this.g.user.wallets, {
id: data.wallet
})
if (wallet) {
return wallet.name
}
}
},
{
name: 'status',
align: 'left',
label: 'Status',
field: 'status'
},
{
name: 'boltz_id',
align: 'left',
label: 'Boltz ID',
field: 'boltz_id'
},
{
name: 'onchain_amount',
align: 'left',
label: 'Onchain amount',
field: 'onchain_amount'
},
{
name: 'timeout_block_height',
align: 'left',
label: 'Timeout block height',
field: 'timeout_block_height'
}
],
pagination: {
rowsPerPage: 10
}
},
submarineSwapTable: {
columns: [
{
name: 'time',
align: 'left',
label: 'Time',
field: 'time',
sortable: true,
format: function (val, row) {
return new Date(val * 1000).toUTCString()
}
},
{
name: 'wallet',
align: 'left',
label: 'Wallet',
field: data => {
let wallet = _.findWhere(this.g.user.wallets, {
id: data.wallet
})
if (wallet) {
return wallet.name
}
}
},
{
name: 'status',
align: 'left',
label: 'Status',
field: 'status'
},
{
name: 'boltz_id',
align: 'left',
label: 'Boltz ID',
field: 'boltz_id'
},
{
name: 'expected_amount',
align: 'left',
label: 'Expected amount',
field: 'expected_amount'
},
{
name: 'timeout_block_height',
align: 'left',
label: 'Timeout block height',
field: 'timeout_block_height'
}
],
pagination: {
rowsPerPage: 10
}
}
}
},
computed: {
boltzExample() {
let amount = 100000
let onchain_lnbits = 1000
let onchain_boltz = 500
let boltz_fee = (amount * this.boltzConfig.fee_percentage) / 100
let normal_fee_total = onchain_boltz + boltz_fee
let reverse_fee_total = onchain_boltz + boltz_fee + onchain_lnbits
return {
amount: amount,
boltz_fee: boltz_fee,
reverse_fee_total: reverse_fee_total,
reverse_receive: amount - reverse_fee_total,
onchain_lnbits: onchain_lnbits,
onchain_boltz: onchain_boltz,
normal_fee_total: normal_fee_total,
normal_expected_amount: amount + normal_fee_total
}
}
},
methods: {
getLimits() {
if (this.boltzConfig) {
return {
min: this.boltzConfig.minimal,
max: this.boltzConfig.maximal
}
}
return {
min: 0,
max: 0
}
},
amountLabel() {
let limits = this.getLimits()
return 'min: (' + limits.min + '), max: (' + limits.max + ')'
},
disableSubmarineSwapDialog() {
const data = this.submarineSwapDialog.data
let limits = this.getLimits()
return (
data.wallet == null ||
data.refund_address == null ||
data.refund_address.search(
/^(bcrt1|bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$/
) !== 0 ||
data.amount < limits.min ||
data.amount > limits.max
)
},
disableReverseSubmarineSwapDialog() {
const data = this.reverseSubmarineSwapDialog.data
let limits = this.getLimits()
return (
data.onchain_address == null ||
data.onchain_address.search(
/^(bcrt1|bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$/
) !== 0 ||
data.wallet == null ||
data.amount < limits.min ||
data.amount > limits.max
)
},
disableAutoReverseSubmarineSwapDialog() {
const data = this.autoReverseSubmarineSwapDialog.data
let limits = this.getLimits()
return (
data.onchain_address == null ||
data.onchain_address.search(
/^(bcrt1|bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$/
) !== 0 ||
data.wallet == null ||
data.amount < limits.min ||
data.amount > limits.max
)
},
downloadRefundFile(swapId) {
let swap = _.findWhere(this.submarineSwaps, {id: swapId})
let json = {
id: swap.boltz_id,
currency: 'BTC',
redeemScript: swap.redeem_script,
privateKey: swap.refund_privkey,
timeoutBlockHeight: swap.timeout_block_height
}
let hiddenElement = document.createElement('a')
hiddenElement.href =
'data:application/json;charset=utf-8,' +
encodeURI(JSON.stringify(json))
hiddenElement.target = '_blank'
hiddenElement.download = 'boltz-refund-' + swap.boltz_id + '.json'
hiddenElement.click()
},
refundSwap(swapId) {
LNbits.api
.request(
'POST',
'/boltz/api/v1/swap/refund?swap_id=' + swapId,
this.g.user.wallets[0].adminkey
)
.then(res => {
this.resetStatusDialog()
})
.catch(error => {
console.log('error', error)
LNbits.utils.notifyApiError(error)
})
},
openMempool(swap_id) {
var swap = _.findWhere(this.submarineSwaps, {id: swap_id})
if (swap === undefined) {
var swap = _.findWhere(this.reverseSubmarineSwaps, {id: swap_id})
var address = swap.lockup_address
} else {
var address = swap.address
}
var mempool_address = this.mempool
// used for development, replace docker hosts with localhost
if (mempool_address.search('mempool-web') !== -1) {
mempool_address = mempool_address.replace('mempool-web', 'localhost')
}
window.open(mempool_address + '/address/' + address, '_blank')
},
openStatusDialog(swap_id, reverse) {
LNbits.api
.request(
'POST',
'/boltz/api/v1/swap/status?swap_id=' + swap_id,
this.g.user.wallets[0].adminkey
)
.then(res => {
this.resetStatusDialog()
this.statusDialog.data = {
reverse: reverse,
swap_id: swap_id,
wallet: res.data.wallet,
boltz: res.data.boltz,
status: res.data.status,
mempool: res.data.mempool,
timeout_block_height: res.data.timeout_block_height,
date: new Date().toUTCString()
}
this.statusDialog.show = true
})
.catch(error => {
console.log('error', error)
LNbits.utils.notifyApiError(error)
})
},
openQrCodeDialog(submarineSwapId) {
var swap = _.findWhere(this.submarineSwaps, {id: submarineSwapId})
if (swap === undefined) {
return console.assert('swap is undefined, this should not happen')
}
this.qrCodeDialog.data = {
id: swap.id,
expected_amount: swap.expected_amount,
expected_amount_btc: swap.expected_amount / 100000000,
bip21: swap.bip21,
address: swap.address
}
this.qrCodeDialog.show = true
},
resetStatusDialog() {
this.statusDialog = {
show: false,
data: {}
}
},
resetSubmarineSwapDialog() {
this.submarineSwapDialog = {
show: false,
data: {}
}
},
resetReverseSubmarineSwapDialog() {
this.reverseSubmarineSwapDialog = {
show: false,
data: {}
}
},
resetAutoReverseSubmarineSwapDialog() {
this.autoReverseSubmarineSwapDialog = {
show: false,
data: {}
}
},
sendReverseSubmarineSwapFormData() {
let wallet = _.findWhere(this.g.user.wallets, {
id: this.reverseSubmarineSwapDialog.data.wallet
})
let data = this.reverseSubmarineSwapDialog.data
this.createReverseSubmarineSwap(wallet, data)
},
sendAutoReverseSubmarineSwapFormData() {
let wallet = _.findWhere(this.g.user.wallets, {
id: this.autoReverseSubmarineSwapDialog.data.wallet
})
let data = this.autoReverseSubmarineSwapDialog.data
this.createAutoReverseSubmarineSwap(wallet, data)
},
sendSubmarineSwapFormData() {
let wallet = _.findWhere(this.g.user.wallets, {
id: this.submarineSwapDialog.data.wallet
})
let data = this.submarineSwapDialog.data
this.createSubmarineSwap(wallet, data)
},
exportSubmarineSwapCSV() {
LNbits.utils.exportCSV(
this.submarineSwapTable.columns,
this.submarineSwaps
)
},
exportReverseSubmarineSwapCSV() {
LNbits.utils.exportCSV(
this.reverseSubmarineSwapTable.columns,
this.reverseSubmarineSwaps
)
},
exportAutoReverseSubmarineSwapCSV() {
LNbits.utils.exportCSV(
this.autoReverseSubmarineSwapTable.columns,
this.autoReverseSubmarineSwaps
)
},
createSubmarineSwap(wallet, data) {
LNbits.api
.request(
'POST',
'/boltz/api/v1/swap',
this.g.user.wallets[0].adminkey,
data
)
.then(res => {
this.submarineSwaps.unshift(res.data)
this.resetSubmarineSwapDialog()
this.openQrCodeDialog(res.data.id)
})
.catch(error => {
LNbits.utils.notifyApiError(error)
})
},
createReverseSubmarineSwap(wallet, data) {
LNbits.api
.request(
'POST',
'/boltz/api/v1/swap/reverse',
this.g.user.wallets[0].adminkey,
data
)
.then(res => {
this.reverseSubmarineSwaps.unshift(res.data)
this.resetReverseSubmarineSwapDialog()
})
.catch(error => {
LNbits.utils.notifyApiError(error)
})
},
createAutoReverseSubmarineSwap(wallet, data) {
LNbits.api
.request(
'POST',
'/boltz/api/v1/swap/reverse/auto',
this.g.user.wallets[0].adminkey,
data
)
.then(res => {
this.autoReverseSubmarineSwaps.unshift(res.data)
this.resetAutoReverseSubmarineSwapDialog()
})
.catch(error => {
LNbits.utils.notifyApiError(error)
})
},
deleteAutoReverseSwap(swap_id) {
LNbits.api
.request(
'DELETE',
'/boltz/api/v1/swap/reverse/auto/' + swap_id,
this.g.user.wallets[0].adminkey
)
.then(res => {
let i = this.autoReverseSubmarineSwaps.findIndex(
swap => swap.id === swap_id
)
this.autoReverseSubmarineSwaps.splice(i, 1)
})
.catch(error => {
console.log(error)
LNbits.utils.notifyApiError(error)
})
},
getSubmarineSwap() {
LNbits.api
.request(
'GET',
'/boltz/api/v1/swap?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(response => {
this.submarineSwaps = response.data
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getReverseSubmarineSwap() {
LNbits.api
.request(
'GET',
'/boltz/api/v1/swap/reverse?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(response => {
this.reverseSubmarineSwaps = response.data
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getAutoReverseSubmarineSwap() {
LNbits.api
.request(
'GET',
'/boltz/api/v1/swap/reverse/auto?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(response => {
this.autoReverseSubmarineSwaps = response.data
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getMempool() {
LNbits.api
.request('GET', '/boltz/api/v1/swap/mempool')
.then(res => {
this.mempool = res.data
})
.catch(error => {
console.log('error', error)
LNbits.utils.notifyApiError(error)
})
},
getBoltzConfig() {
LNbits.api
.request('GET', '/boltz/api/v1/swap/boltz')
.then(res => {
this.boltzConfig = res.data
})
.catch(error => {
console.log('error', error)
LNbits.utils.notifyApiError(error)
})
}
},
created: function () {
this.getMempool()
this.getBoltzConfig()
this.getSubmarineSwap()
this.getReverseSubmarineSwap()
this.getAutoReverseSubmarineSwap()
}
})
</script>
{% endblock %}

View file

@ -1,87 +0,0 @@
import asyncio
import calendar
import datetime
from typing import Awaitable
from boltz_client.boltz import BoltzClient, BoltzConfig
from lnbits.core.services import fee_reserve, get_wallet, pay_invoice
from lnbits.settings import settings
from .models import ReverseSubmarineSwap
def create_boltz_client() -> BoltzClient:
config = BoltzConfig(
network=settings.boltz_network,
api_url=settings.boltz_url,
mempool_url=f"{settings.boltz_mempool_space_url}/api",
mempool_ws_url=f"{settings.boltz_mempool_space_url_ws}/api/v1/ws",
referral_id="lnbits",
)
return BoltzClient(config)
async def check_balance(data) -> bool:
# check if we can pay the invoice before we create the actual swap on boltz
amount_msat = data.amount * 1000
fee_reserve_msat = fee_reserve(amount_msat)
wallet = await get_wallet(data.wallet)
assert wallet
if wallet.balance_msat - fee_reserve_msat < amount_msat:
return False
return True
def get_timestamp():
date = datetime.datetime.utcnow()
return calendar.timegm(date.utctimetuple())
async def execute_reverse_swap(client, swap: ReverseSubmarineSwap):
# claim_task is watching onchain address for the lockup transaction to arrive / confirm
# and if the lockup is there, claim the onchain revealing preimage for hold invoice
claim_task = asyncio.create_task(
client.claim_reverse_swap(
privkey_wif=swap.claim_privkey,
preimage_hex=swap.preimage,
lockup_address=swap.lockup_address,
receive_address=swap.onchain_address,
redeem_script_hex=swap.redeem_script,
)
)
# pay_task is paying the hold invoice which gets held until you reveal your preimage when claiming your onchain funds
pay_task = pay_invoice_and_update_status(
swap.id,
claim_task,
pay_invoice(
wallet_id=swap.wallet,
payment_request=swap.invoice,
description=f"reverse swap for {swap.onchain_amount} sats on boltz.exchange",
extra={"tag": "boltz", "swap_id": swap.id, "reverse": True},
),
)
# they need to run be concurrently, because else pay_task will lock the eventloop and claim_task will not be executed.
# the lockup transaction can only happen after you pay the invoice, which cannot be redeemed immediatly -> hold invoice
# after getting the lockup transaction, you can claim the onchain funds revealing the preimage for boltz to redeem the hold invoice
asyncio.gather(claim_task, pay_task)
def pay_invoice_and_update_status(
swap_id: str, wstask: asyncio.Task, awaitable: Awaitable
) -> asyncio.Task:
async def _pay_invoice(awaitable):
from .crud import update_swap_status
try:
awaited = await awaitable
await update_swap_status(swap_id, "complete")
return awaited
except asyncio.exceptions.CancelledError:
"""lnbits process was exited, do nothing and handle it in startup script"""
except:
wstask.cancel()
await update_swap_status(swap_id, "failed")
return asyncio.create_task(_pay_invoice(awaitable))

View file

@ -1,21 +0,0 @@
from urllib.parse import urlparse
from fastapi import Depends, Request
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import boltz_ext, boltz_renderer
templates = Jinja2Templates(directory="templates")
@boltz_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
root_url = urlparse(str(request.url)).netloc
return boltz_renderer().TemplateResponse(
"boltz/index.html",
{"request": request, "user": user.dict(), "root_url": root_url},
)

View file

@ -1,332 +0,0 @@
from http import HTTPStatus
from typing import List
from fastapi import Depends, Query, status
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import create_invoice
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.helpers import urlsafe_short_hash
from lnbits.settings import settings
from . import boltz_ext
from .crud import (
create_auto_reverse_submarine_swap,
create_reverse_submarine_swap,
create_submarine_swap,
delete_auto_reverse_submarine_swap,
get_auto_reverse_submarine_swap_by_wallet,
get_auto_reverse_submarine_swaps,
get_reverse_submarine_swap,
get_reverse_submarine_swaps,
get_submarine_swap,
get_submarine_swaps,
update_swap_status,
)
from .models import (
AutoReverseSubmarineSwap,
CreateAutoReverseSubmarineSwap,
CreateReverseSubmarineSwap,
CreateSubmarineSwap,
ReverseSubmarineSwap,
SubmarineSwap,
)
from .utils import check_balance, create_boltz_client, execute_reverse_swap
@boltz_ext.get(
"/api/v1/swap/mempool",
name="boltz.get /swap/mempool",
summary="get a the mempool url",
description="""
This endpoint gets the URL from mempool.space
""",
response_description="mempool.space url",
response_model=str,
)
async def api_mempool_url():
return settings.boltz_mempool_space_url
# NORMAL SWAP
@boltz_ext.get(
"/api/v1/swap",
name="boltz.get /swap",
summary="get a list of swaps a swap",
description="""
This endpoint gets a list of normal swaps.
""",
response_description="list of normal swaps",
dependencies=[Depends(get_key_type)],
response_model=List[SubmarineSwap],
)
async def api_submarineswap(
g: WalletTypeInfo = Depends(get_key_type),
all_wallets: bool = Query(False),
):
wallet_ids = [g.wallet.id]
if all_wallets:
user = await get_user(g.wallet.user)
wallet_ids = user.wallet_ids if user else []
return [swap.dict() for swap in await get_submarine_swaps(wallet_ids)]
@boltz_ext.post(
"/api/v1/swap/refund",
name="boltz.swap_refund",
summary="refund of a swap",
description="""
This endpoint attempts to refund a normal swaps, creates onchain tx and sets swap status ro refunded.
""",
response_description="refunded swap with status set to refunded",
dependencies=[Depends(require_admin_key)],
response_model=SubmarineSwap,
responses={
400: {"description": "when swap_id is missing"},
404: {"description": "when swap is not found"},
405: {"description": "when swap is not pending"},
500: {
"description": "when something goes wrong creating the refund onchain tx"
},
},
)
async def api_submarineswap_refund(swap_id: str):
if not swap_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="swap_id missing"
)
swap = await get_submarine_swap(swap_id)
if not swap:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="swap does not exist."
)
if swap.status != "pending":
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="swap is not pending."
)
client = create_boltz_client()
await client.refund_swap(
privkey_wif=swap.refund_privkey,
lockup_address=swap.address,
receive_address=swap.refund_address,
redeem_script_hex=swap.redeem_script,
timeout_block_height=swap.timeout_block_height,
)
await update_swap_status(swap.id, "refunded")
return swap
@boltz_ext.post(
"/api/v1/swap",
status_code=status.HTTP_201_CREATED,
name="boltz.post /swap",
summary="create a submarine swap",
description="""
This endpoint creates a submarine swap
""",
response_description="create swap",
response_model=SubmarineSwap,
dependencies=[Depends(require_admin_key)],
responses={
405: {
"description": "auto reverse swap is active, a swap would immediatly be swapped out again."
},
500: {"description": "boltz error"},
},
)
async def api_submarineswap_create(data: CreateSubmarineSwap):
auto_swap = await get_auto_reverse_submarine_swap_by_wallet(data.wallet)
if auto_swap:
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
detail="auto reverse swap is active, a swap would immediatly be swapped out again.",
)
client = create_boltz_client()
swap_id = urlsafe_short_hash()
payment_hash, payment_request = await create_invoice(
wallet_id=data.wallet,
amount=data.amount,
memo=f"swap of {data.amount} sats on boltz.exchange",
extra={"tag": "boltz", "swap_id": swap_id},
)
refund_privkey_wif, swap = client.create_swap(payment_request)
new_swap = await create_submarine_swap(
data, swap, swap_id, refund_privkey_wif, payment_hash
)
return new_swap.dict() if new_swap else None
# REVERSE SWAP
@boltz_ext.get(
"/api/v1/swap/reverse",
name="boltz.get /swap/reverse",
summary="get a list of reverse swaps",
description="""
This endpoint gets a list of reverse swaps.
""",
response_description="list of reverse swaps",
dependencies=[Depends(get_key_type)],
response_model=List[ReverseSubmarineSwap],
)
async def api_reverse_submarineswap(
g: WalletTypeInfo = Depends(get_key_type),
all_wallets: bool = Query(False),
):
wallet_ids = [g.wallet.id]
if all_wallets:
user = await get_user(g.wallet.user)
wallet_ids = user.wallet_ids if user else []
return [swap for swap in await get_reverse_submarine_swaps(wallet_ids)]
@boltz_ext.post(
"/api/v1/swap/reverse",
status_code=status.HTTP_201_CREATED,
name="boltz.post /swap/reverse",
summary="create a reverse submarine swap",
description="""
This endpoint creates a reverse submarine swap
""",
response_description="create reverse swap",
response_model=ReverseSubmarineSwap,
dependencies=[Depends(require_admin_key)],
responses={
405: {"description": "not allowed method, insufficient balance"},
500: {"description": "boltz error"},
},
)
async def api_reverse_submarineswap_create(
data: CreateReverseSubmarineSwap,
) -> ReverseSubmarineSwap:
if not await check_balance(data):
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="Insufficient balance."
)
client = create_boltz_client()
claim_privkey_wif, preimage_hex, swap = client.create_reverse_swap(
amount=data.amount
)
new_swap = await create_reverse_submarine_swap(
data, claim_privkey_wif, preimage_hex, swap
)
await execute_reverse_swap(client, new_swap)
return new_swap
@boltz_ext.get(
"/api/v1/swap/reverse/auto",
name="boltz.get /swap/reverse/auto",
summary="get a list of auto reverse swaps",
description="""
This endpoint gets a list of auto reverse swaps.
""",
response_description="list of auto reverse swaps",
dependencies=[Depends(get_key_type)],
response_model=List[AutoReverseSubmarineSwap],
)
async def api_auto_reverse_submarineswap(
g: WalletTypeInfo = Depends(get_key_type),
all_wallets: bool = Query(False),
):
wallet_ids = [g.wallet.id]
if all_wallets:
user = await get_user(g.wallet.user)
wallet_ids = user.wallet_ids if user else []
return [swap.dict() for swap in await get_auto_reverse_submarine_swaps(wallet_ids)]
@boltz_ext.post(
"/api/v1/swap/reverse/auto",
status_code=status.HTTP_201_CREATED,
name="boltz.post /swap/reverse/auto",
summary="create a auto reverse submarine swap",
description="""
This endpoint creates a auto reverse submarine swap
""",
response_description="create auto reverse swap",
response_model=AutoReverseSubmarineSwap,
dependencies=[Depends(require_admin_key)],
responses={
405: {
"description": "auto reverse swap is active, only 1 swap per wallet possible."
},
},
)
async def api_auto_reverse_submarineswap_create(data: CreateAutoReverseSubmarineSwap):
auto_swap = await get_auto_reverse_submarine_swap_by_wallet(data.wallet)
if auto_swap:
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
detail="auto reverse swap is active, only 1 swap per wallet possible.",
)
swap = await create_auto_reverse_submarine_swap(data)
return swap.dict() if swap else None
@boltz_ext.delete(
"/api/v1/swap/reverse/auto/{swap_id}",
name="boltz.delete /swap/reverse/auto",
summary="delete a auto reverse submarine swap",
description="""
This endpoint deletes a auto reverse submarine swap
""",
response_description="delete auto reverse swap",
dependencies=[Depends(require_admin_key)],
)
async def api_auto_reverse_submarineswap_delete(swap_id: str):
await delete_auto_reverse_submarine_swap(swap_id)
return "OK"
@boltz_ext.post(
"/api/v1/swap/status",
name="boltz.swap_status",
summary="shows the status of a swap",
description="""
This endpoint attempts to get the status of the swap.
""",
response_description="status of swap json",
dependencies=[Depends(require_admin_key)],
responses={
404: {"description": "when swap_id is not found"},
},
)
async def api_swap_status(swap_id: str):
swap = await get_submarine_swap(swap_id) or await get_reverse_submarine_swap(
swap_id
)
if not swap:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="swap does not exist."
)
client = create_boltz_client()
status = client.swap_status(swap.boltz_id)
return status
@boltz_ext.get(
"/api/v1/swap/boltz",
name="boltz.get /swap/boltz",
summary="get a boltz configuration",
description="""
This endpoint gets configuration for boltz. (limits, fees...)
""",
response_description="dict of boltz config",
response_model=dict,
)
async def api_boltz_config():
client = create_boltz_client()
return {
"minimal": client.limit_minimal,
"maximal": client.limit_maximal,
"fee_percentage": client.fee_percentage,
}

View file

@ -1,11 +0,0 @@
# Cashu
## Create ecash mint for pegging in/out of ecash
### Usage
1. Enable extension
2. Create a Mint
3. Share wallet

View file

@ -1,47 +0,0 @@
import asyncio
from environs import Env
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_cashu")
cashu_static_files = [
{
"path": "/cashu/static",
"app": StaticFiles(directory="lnbits/extensions/cashu/static"),
"name": "cashu_static",
}
]
from cashu.mint.ledger import Ledger
env = Env()
env.read_env()
ledger = Ledger(
db=db,
seed=env.str("CASHU_PRIVATE_KEY", default="SuperSecretPrivateKey"),
derivation_path="0/0/0/1",
)
cashu_ext: APIRouter = APIRouter(prefix="/cashu", tags=["cashu"])
def cashu_renderer():
return template_renderer(["lnbits/extensions/cashu/templates"])
from .tasks import startup_cashu_mint, wait_for_paid_invoices
from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403
def cashu_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(startup_cashu_mint))
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View file

@ -1,7 +0,0 @@
{
"name": "Cashu",
"short_description": "Ecash mint and wallet",
"tile": "/cashu/static/image/cashu.png",
"contributors": ["calle", "vlad", "arcbtc"],
"hidden": false
}

View file

@ -1,51 +0,0 @@
from typing import List, Optional, Union
from . import db
from .models import Cashu
async def create_cashu(
cashu_id: str, keyset_id: str, wallet_id: str, data: Cashu
) -> Cashu:
await db.execute(
"""
INSERT INTO cashu.cashu (id, wallet, name, tickershort, fraction, maxsats, coins, keyset_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
cashu_id,
wallet_id,
data.name,
data.tickershort,
data.fraction,
data.maxsats,
data.coins,
keyset_id,
),
)
cashu = await get_cashu(cashu_id)
assert cashu, "Newly created cashu couldn't be retrieved"
return cashu
async def get_cashu(cashu_id) -> 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) -> None:
await db.execute("DELETE FROM cashu.cashu WHERE id = ?", (cashu_id,))

View file

@ -1,33 +0,0 @@
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 DEFAULT 'sats',
fraction BOOL,
maxsats INT,
coins INT,
keyset_id TEXT NOT NULL,
issued_sat 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
);
"""
)

View file

@ -1,148 +0,0 @@
from sqlite3 import Row
from typing import List
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 = Query(None)
fraction: bool = Query(None)
maxsats: int = Query(0)
coins: int = Query(0)
keyset_id: str = Query(None)
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class Pegs(BaseModel):
id: str
wallet: str
inout: str
amount: str
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class PayLnurlWData(BaseModel):
lnurl: str
class Promises(BaseModel):
id: str
amount: int
B_b: str
C_b: str
cashu_id: str
class Proof(BaseModel):
amount: int
secret: str
C: str
reserved: bool = False # whether this proof is reserved for sending
send_id: str = "" # unique ID of send attempt
time_created: str = ""
time_reserved: str = ""
@classmethod
def from_row(cls, row: Row):
return cls(
amount=row[0],
C=row[1],
secret=row[2],
reserved=row[3] or False,
send_id=row[4] or "",
time_created=row[5] or "",
time_reserved=row[6] or "",
)
@classmethod
def from_dict(cls, d: dict):
assert "secret" in d, "no secret in proof"
assert "amount" in d, "no amount in proof"
assert "C" in d, "no C in proof"
return cls(
amount=d["amount"],
C=d["C"],
secret=d["secret"],
reserved=d.get("reserved") or False,
send_id=d.get("send_id") or "",
time_created=d.get("time_created") or "",
time_reserved=d.get("time_reserved") or "",
)
def to_dict(self):
return dict(amount=self.amount, secret=self.secret, C=self.C)
def __getitem__(self, key):
return self.__getattribute__(key)
def __setitem__(self, key, val):
self.__setattr__(key, val)
class Proofs(BaseModel):
"""TODO: Use this model"""
proofs: List[Proof]
class Invoice(BaseModel):
amount: int
pr: str
hash: str
issued: bool = False
@classmethod
def from_row(cls, row: Row):
return cls(
amount=int(row[0]),
pr=str(row[1]),
hash=str(row[2]),
issued=bool(row[3]),
)
class BlindedMessage(BaseModel):
amount: int
B_: str
class BlindedSignature(BaseModel):
amount: int
C_: str
@classmethod
def from_dict(cls, d: dict):
return cls(
amount=d["amount"],
C_=d["C_"],
)
class MintPayloads(BaseModel):
blinded_messages: List[BlindedMessage] = []
class SplitPayload(BaseModel):
proofs: List[Proof]
amount: int
output_data: MintPayloads
class CheckPayload(BaseModel):
proofs: List[Proof]
class MeltPayload(BaseModel):
proofs: List[Proof]
amount: int
invoice: str

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View file

@ -1,37 +0,0 @@
function unescapeBase64Url(str) {
return (str + '==='.slice((str.length + 3) % 4))
.replace(/-/g, '+')
.replace(/_/g, '/')
}
function escapeBase64Url(str) {
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
const uint8ToBase64 = (function (exports) {
'use strict'
var fromCharCode = String.fromCharCode
var encode = function encode(uint8array) {
var output = []
for (var i = 0, length = uint8array.length; i < length; i++) {
output.push(fromCharCode(uint8array[i]))
}
return btoa(output.join(''))
}
var asCharCode = function asCharCode(c) {
return c.charCodeAt(0)
}
var decode = function decode(chars) {
return Uint8Array.from(atob(chars), asCharCode)
}
exports.decode = decode
exports.encode = encode
return exports
})({})

View file

@ -1,31 +0,0 @@
async function hashToCurve(secretMessage) {
let point
while (!point) {
const hash = await nobleSecp256k1.utils.sha256(secretMessage)
const hashHex = nobleSecp256k1.utils.bytesToHex(hash)
const pointX = '02' + hashHex
try {
point = nobleSecp256k1.Point.fromHex(pointX)
} catch (error) {
secretMessage = await nobleSecp256k1.utils.sha256(secretMessage)
}
}
return point
}
async function step1Alice(secretMessage) {
secretMessage = uint8ToBase64.encode(secretMessage)
secretMessage = new TextEncoder().encode(secretMessage)
const Y = await hashToCurve(secretMessage)
const r_bytes = nobleSecp256k1.utils.randomPrivateKey()
const r = bytesToNumber(r_bytes)
const P = nobleSecp256k1.Point.fromPrivateKey(r)
const B_ = Y.add(P)
return {B_: B_.toHex(true), r: nobleSecp256k1.utils.bytesToHex(r_bytes)}
}
function step3Alice(C_, r, A) {
const rInt = bytesToNumber(r)
const C = C_.subtract(A.multiply(rInt))
return C
}

File diff suppressed because it is too large Load diff

View file

@ -1,23 +0,0 @@
function splitAmount(value) {
const chunks = []
for (let i = 0; i < 32; i++) {
const mask = 1 << i
if ((value & mask) !== 0) chunks.push(Math.pow(2, i))
}
return chunks
}
function bytesToNumber(bytes) {
return hexToNumber(nobleSecp256k1.utils.bytesToHex(bytes))
}
function bigIntStringify(key, value) {
return typeof value === 'bigint' ? value.toString() : value
}
function hexToNumber(hex) {
if (typeof hex !== 'string') {
throw new TypeError('hexToNumber: expected string, got ' + typeof hex)
}
return BigInt(`0x${hex}`)
}

View file

@ -1,31 +0,0 @@
import asyncio
from cashu.core.migrations import migrate_databases
from cashu.mint import migrations
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from . import db, ledger
async def startup_cashu_mint():
await migrate_databases(db, migrations)
await ledger.load_used_proofs()
await ledger.init_keysets(autosave=False)
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":
return
return

View file

@ -1,80 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/cashu"></q-btn>
<!-- <q-expansion-item group="api" dense expand-separator label="List TPoS">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /cashu/api/v1/mints</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;cashu_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}cashu/api/v1/mints -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create a TPoS">
<q-card>
<q-card-section>
<code><span class="text-green">POST</span> /cashu/api/v1/mints</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"name": &lt;string&gt;, "currency": &lt;string*ie USD*&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"currency": &lt;string&gt;, "id": &lt;string&gt;, "name":
&lt;string&gt;, "wallet": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}cashu/api/v1/mints -d '{"name":
&lt;string&gt;, "currency": &lt;string&gt;}' -H "Content-type:
application/json" -H "X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a TPoS"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/cashu/api/v1/mints/&lt;cashu_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.base_url
}}cashu/api/v1/mints/&lt;cashu_id&gt; -H "X-Api-Key:
&lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item> -->
</q-expansion-item>

View file

@ -1,28 +0,0 @@
<q-expansion-item group="extras" icon="info" label="About">
<q-card>
<q-card-section>
<p>Create Cashu ecash mints and wallets.</p>
<small
>Created by
<a
class="text-secondary"
href="https://github.com/arcbtc"
target="_blank"
>arcbtc</a
>,
<a
class="text-secondary"
href="https://github.com/motorina0"
target="_blank"
>vlad</a
>,
<a
class="text-secondary"
href="https://github.com/calle"
target="_blank"
>calle</a
>.</small
>
</q-card-section>
</q-card>
</q-expansion-item>

View file

@ -1,367 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<b>Cashu mint and wallet</b>
<p></p>
<p>
Here you can create multiple cashu mints that you can share. Each mint
can service many users but all ecash tokens of a mint are only valid
inside that mint and not across different mints. To exchange funds
between mints, use Lightning payments.
</p>
<b>Important</b>
<p></p>
<p>
If you are the operator of this LNbits instance, make sure to set
CASHU_PRIVATE_KEY="randomkey" in your configuration file. Do not
create mints before setting the key and do not change the key once
set.
</p>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Mints</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="cashus"
row-key="id"
:columns="cashusTable.columns"
:pagination.sync="cashusTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="account_balance_wallet"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'wallet/?' + 'mint_id=' + props.row.id"
target="_blank"
><q-tooltip>Shareable wallet</q-tooltip></q-btn
>
<q-btn
unelevated
dense
size="xs"
icon="account_balance"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'mint/' + props.row.id"
target="_blank"
><q-tooltip>Shareable mint page</q-tooltip></q-btn
>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ (col.name == 'tip_options' && col.value ?
JSON.parse(col.value).join(", ") : col.value) }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteMint(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
<q-btn
class="q-pt-l"
unelevated
color="primary"
@click="formDialog.show = true"
>New Mint</q-btn
>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} Cashu extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "cashu/_api_docs.html" %}
<q-separator></q-separator>
{% include "cashu/_cashu.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="createMint" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
label="Mint Name"
placeholder="Cashu Mint"
></q-input>
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Cashu wallet *"
></q-select>
<!-- <q-toggle
v-model="toggleAdvanced"
label="Show advanced options"
></q-toggle>
<div v-show="toggleAdvanced">
<div class="row">
<div class="col-5">
<q-checkbox
v-model="formDialog.data.fraction"
color="primary"
label="sats/coins?"
>
<q-tooltip
>Use with hedging extension to create a stablecoin!</q-tooltip
>
</q-checkbox>
</div>
<div class="col-7">
<q-input
v-if="!formDialog.data.fraction"
filled
dense
type="number"
v-model.trim="formDialog.data.cost"
label="Sat coin cost (optional)"
value="1"
type="number"
></q-input>
<q-input
v-if="!formDialog.data.fraction"
filled
dense
v-model.trim="formDialog.data.tickershort"
label="Ticker shorthand"
placeholder="sats"
#
></q-input>
</div>
</div>
<q-input
class="q-mt-md"
filled
dense
type="number"
v-model.trim="formDialog.data.maxsats"
label="Maximum mint liquidity (optional)"
placeholder="∞"
></q-input>
<q-input
class="q-mt-md"
filled
dense
type="number"
v-model.trim="formDialog.data.coins"
label="Coins that 'exist' in mint (optional)"
placeholder="∞"
></q-input>
</div> -->
<div class="row q-mt-md">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.name == null"
type="submit"
>Create Mint
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapMint = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.cashu = ['/cashu/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
cashus: [],
hostname: location.protocol + '//' + location.host + '/cashu/mint/',
toggleAdvanced: false,
cashusTable: {
columns: [
{name: 'id', align: 'left', label: 'Mint ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
// {
// name: 'tickershort',
// align: 'left',
// label: 'Ticker',
// field: 'tickershort'
// },
{
name: 'wallet',
align: 'left',
label: 'Mint wallet',
field: 'wallet'
}
// {
// name: 'fraction',
// align: 'left',
// label: 'Using fraction',
// field: 'fraction'
// },
// {
// name: 'maxsats',
// align: 'left',
// label: 'Max Sats',
// field: 'maxsats'
// },
// {
// name: 'coins',
// align: 'left',
// label: 'No. of coins',
// field: 'coins'
// }
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {fraction: false}
}
}
},
methods: {
closeFormDialog: function () {
this.formDialog.data = {}
},
getMints: function () {
var self = this
LNbits.api
.request(
'GET',
'/cashu/api/v1/mints?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.cashus = response.data.map(function (obj) {
return mapMint(obj)
})
})
},
createMint: function () {
if (this.formDialog.data.maxliquid == null) {
this.formDialog.data.maxliquid = 0
}
var data = {
name: this.formDialog.data.name,
tickershort: this.formDialog.data.tickershort,
maxliquid: this.formDialog.data.maxliquid
}
var self = this
LNbits.api
.request(
'POST',
'/cashu/api/v1/mints',
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
self.cashus.push(mapMint(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteMint: function (cashuId) {
var self = this
var cashu = _.findWhere(this.cashus, {id: cashuId})
console.log(cashu)
LNbits.utils
.confirmDialog(
"Are you sure you want to delete this Mint? This mint's users will not be able to redeem their tokens!"
)
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/cashu/api/v1/mints/' + cashuId,
_.findWhere(self.g.user.wallets, {id: cashu.wallet}).adminkey
)
.then(function (response) {
self.cashus = _.reject(self.cashus, function (obj) {
return obj.id == cashuId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.cashusTable.columns, this.cashus)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getMints()
}
}
})
</script>
{% endblock %}

View file

@ -1,92 +0,0 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg q-mb-xl">
<q-card-section class="q-pa-none">
<center>
<q-img
src="/cashu/static/image/cashu.png"
spinner-color="white"
style="max-width: 20%"
></q-img>
<h4 class="q-mt-sm q-mb-md">{{ mint_name }}</h4>
<!-- <a class="text-secondary">Mint URL: {{testfield}} </a> <br /> -->
<a
class="text-secondary"
class="q-my-xl text-white"
style="font-size: 1.5rem"
href="../wallet?mint_id={{ mint_id }}"
>click to open wallet</a
>
</center>
</q-card-section>
</q-card>
<q-card class="q-pa-lg q-mb-xl">
<q-card-section class="q-pa-none">
<h5 class="q-my-md">Read the following carefully!</h5>
<p>
This is a
<a
class="text-secondary"
href="https://cashu.space/"
style="color: white"
target="”_blank”"
>Cashu</a
>
mint. Cashu is an ecash system for Bitcoin.
</p>
<p>
<strong>Open this page in your native browser</strong><br />
Before you continue to the wallet, make sure to open this page in your
device's native browser application (Safari for iOS, Chrome for
Android). Do not use Cashu in an embedded browser that opens when you
click a link in a messenger.
</p>
<p>
<strong>Add wallet to home screen</strong><br />
You can add Cashu to your home screen as a progressive web app (PWA).
After opening the wallet in your browser (click the link above), on
Android (Chrome), click the menu at the upper right. On iOS (Safari),
click the share button. Now press the Add to Home screen button.
</p>
<p>
<strong>Backup your wallet</strong><br />
Ecash is a bearer asset. That means losing access to your wallet will
make you lose your funds. The wallet stores ecash tokens on your
device's database. If you lose the link or delete your your data
without backing up, you will lose your tokens. Press the Backup button
in the wallet to download a copy of your tokens.
</p>
<p>
<strong>This service is in BETA</strong> <br />
Cashu is still experimental and in active development. There are
likely bugs in this implementation so please use this with caution. We
hold no responsibility for people losing access to funds. Use at your
own risk!
</p>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
testfield: 'asd',
mintURL: {
location: window.location,
base_url: location.protocol + '//' + location.host + location.pathname
}
}
}
})
</script>
{% endblock %}

File diff suppressed because it is too large Load diff

View file

@ -1,253 +0,0 @@
from http import HTTPStatus
from typing import Optional
from fastapi import Depends, Request
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 . 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 wallet(request: Request, mint_id: Optional[str] = None):
if mint_id is not None:
cashu = await get_cashu(mint_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
manifest_url = f"/cashu/manifest/{mint_id}.webmanifest"
mint_name = cashu.name
else:
manifest_url = "/cashu/cashu.webmanifest"
mint_name = "Cashu mint"
return cashu_renderer().TemplateResponse(
"cashu/wallet.html",
{
"request": request,
"web_manifest": manifest_url,
"mint_name": mint_name,
},
)
@cashu_ext.get("/mint/{mintID}")
async def cashu(request: Request, mintID):
cashu = await get_cashu(mintID)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return cashu_renderer().TemplateResponse(
"cashu/mint.html",
{"request": request, "mint_id": mintID},
)
@cashu_ext.get("/manifest/{cashu_id}.webmanifest")
async def manifest_lnbits(cashu_id: str):
cashu = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return get_manifest(cashu_id, cashu.name)
@cashu_ext.get("/cashu.webmanifest")
async def manifest():
return get_manifest()
def get_manifest(mint_id: Optional[str] = None, mint_name: Optional[str] = None):
manifest_name = "Cashu"
if mint_name:
manifest_name += " - " + mint_name
manifest_url = "/cashu/wallet"
if mint_id:
manifest_url += "?mint_id=" + mint_id
return {
"short_name": "Cashu",
"name": manifest_name,
"icons": [
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/512x512.png",
"type": "image/png",
"sizes": "512x512",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/96x96.png",
"type": "image/png",
"sizes": "96x96",
},
],
"id": manifest_url,
"start_url": manifest_url,
"background_color": "#1F2234",
"description": "Cashu ecash wallet",
"display": "standalone",
"scope": "/cashu/",
"theme_color": "#1F2234",
"protocol_handlers": [
{"protocol": "web+cashu", "url": "&recv_token=%s"},
{"protocol": "web+lightning", "url": "&lightning=%s"},
],
"shortcuts": [
{
"name": manifest_name,
"short_name": "Cashu",
"description": manifest_name,
"url": manifest_url,
"icons": [
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/512x512.png",
"sizes": "512x512",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/192x192.png",
"sizes": "192x192",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/144x144.png",
"sizes": "144x144",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/96x96.png",
"sizes": "96x96",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/72x72.png",
"sizes": "72x72",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/48x48.png",
"sizes": "48x48",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/16x16.png",
"sizes": "16x16",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/20x20.png",
"sizes": "20x20",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/29x29.png",
"sizes": "29x29",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/32x32.png",
"sizes": "32x32",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/40x40.png",
"sizes": "40x40",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/50x50.png",
"sizes": "50x50",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/57x57.png",
"sizes": "57x57",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/58x58.png",
"sizes": "58x58",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/60x60.png",
"sizes": "60x60",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/64x64.png",
"sizes": "64x64",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/72x72.png",
"sizes": "72x72",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/76x76.png",
"sizes": "76x76",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/80x80.png",
"sizes": "80x80",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/87x87.png",
"sizes": "87x87",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/100x100.png",
"sizes": "100x100",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/114x114.png",
"sizes": "114x114",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/120x120.png",
"sizes": "120x120",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/128x128.png",
"sizes": "128x128",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/144x144.png",
"sizes": "144x144",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/152x152.png",
"sizes": "152x152",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/167x167.png",
"sizes": "167x167",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/180x180.png",
"sizes": "180x180",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/192x192.png",
"sizes": "192x192",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/256x256.png",
"sizes": "256x256",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/512x512.png",
"sizes": "512x512",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/1024x1024.png",
"sizes": "1024x1024",
},
],
}
],
}

View file

@ -1,440 +0,0 @@
import math
from http import HTTPStatus
from typing import Dict, Union
# -------- cashu imports
from cashu.core.base import (
CheckFeesRequest,
CheckFeesResponse,
CheckSpendableRequest,
CheckSpendableResponse,
GetMeltResponse,
GetMintResponse,
Invoice,
PostMeltRequest,
PostMintRequest,
PostMintResponse,
PostSplitRequest,
PostSplitResponse,
)
from fastapi import Depends, Query
from loguru import logger
from starlette.exceptions import HTTPException
from lnbits import bolt11
from lnbits.core.crud import check_internal, get_user
from lnbits.core.services import (
check_transaction_status,
create_invoice,
fee_reserve,
pay_invoice,
)
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.helpers import urlsafe_short_hash
from lnbits.wallets.base import PaymentStatus
from . import cashu_ext, ledger
from .crud import create_cashu, delete_cashu, get_cashu, get_cashus
from .models import Cashu
# --------- extension imports
# WARNING: Do not set this to False in production! This will create
# tokens for free otherwise. This is for testing purposes only!
LIGHTNING = True
if not LIGHTNING:
logger.warning(
"Cashu: LIGHTNING is set False! That means that I will create ecash for free!"
)
########################################
############### LNBITS MINTS ###########
########################################
@cashu_ext.get("/api/v1/mints", status_code=HTTPStatus.OK)
async def api_cashus(
all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)
):
"""
Get all mints of this wallet.
"""
wallet_ids = [wallet.wallet.id]
if all_wallets:
user = await get_user(wallet.wallet.user)
if user:
wallet_ids = user.wallet_ids
return [cashu.dict() for cashu in await get_cashus(wallet_ids)]
@cashu_ext.post("/api/v1/mints", status_code=HTTPStatus.CREATED)
async def api_cashu_create(
data: Cashu,
wallet: WalletTypeInfo = Depends(get_key_type),
):
"""
Create a new mint for this wallet.
"""
cashu_id = urlsafe_short_hash()
# generate a new keyset in cashu
keyset = await ledger.load_keyset(cashu_id)
cashu = await create_cashu(
cashu_id=cashu_id, keyset_id=keyset.id, wallet_id=wallet.wallet.id, data=data
)
logger.debug(cashu)
return cashu.dict()
@cashu_ext.delete("/api/v1/mints/{cashu_id}")
async def api_cashu_delete(
cashu_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
"""
Delete an existing cashu mint.
"""
cashu = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Cashu mint does not exist."
)
if cashu.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Not your Cashu mint."
)
await delete_cashu(cashu_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
#######################################
########### CASHU ENDPOINTS ###########
#######################################
@cashu_ext.get("/api/v1/{cashu_id}/keys", status_code=HTTPStatus.OK)
async def keys(cashu_id: str = Query(None)) -> dict[int, str]:
"""Get the public keys of the mint"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return ledger.get_keyset(keyset_id=cashu.keyset_id)
@cashu_ext.get("/api/v1/{cashu_id}/keys/{idBase64Urlsafe}")
async def keyset_keys(
cashu_id: str = Query(None), idBase64Urlsafe: str = Query(None)
) -> dict[int, str]:
"""
Get the public keys of the mint of a specificy keyset id.
The id is encoded in base64_urlsafe and needs to be converted back to
normal base64 before it can be processed.
"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
id = idBase64Urlsafe.replace("-", "+").replace("_", "/")
keyset = ledger.get_keyset(keyset_id=id)
return keyset
@cashu_ext.get("/api/v1/{cashu_id}/keysets", status_code=HTTPStatus.OK)
async def keysets(cashu_id: str = Query(None)) -> dict[str, list[str]]:
"""Get the public keys of the mint"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return {"keysets": [cashu.keyset_id]}
@cashu_ext.get("/api/v1/{cashu_id}/mint")
async def request_mint(cashu_id: str = Query(None), amount: int = 0) -> GetMintResponse:
"""
Request minting of new tokens. The mint responds with a Lightning invoice.
This endpoint can be used for a Lightning invoice UX flow.
Call `POST /mint` after paying the invoice.
"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
# create an invoice that the wallet needs to pay
try:
payment_hash, payment_request = await create_invoice(
wallet_id=cashu.wallet,
amount=amount,
memo=f"{cashu.name}",
extra={"tag": "cashu"},
)
invoice = Invoice(
amount=amount, pr=payment_request, hash=payment_hash, issued=False
)
# await store_lightning_invoice(cashu_id, invoice)
await ledger.crud.store_lightning_invoice(invoice=invoice, db=ledger.db)
except Exception as e:
logger.error(e)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
print(f"Lightning invoice: {payment_request}")
resp = GetMintResponse(pr=payment_request, hash=payment_hash)
# return {"pr": payment_request, "hash": payment_hash}
return resp
@cashu_ext.post("/api/v1/{cashu_id}/mint")
async def mint(
data: PostMintRequest,
cashu_id: str = Query(None),
payment_hash: str = Query(None),
) -> PostMintResponse:
"""
Requests the minting of tokens belonging to a paid payment request.
Call this endpoint after `GET /mint`.
"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
keyset = ledger.keysets.keysets[cashu.keyset_id]
if LIGHTNING:
invoice: Invoice = await ledger.crud.get_lightning_invoice(
db=ledger.db, hash=payment_hash
)
if invoice is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Mint does not know this invoice.",
)
if invoice.issued:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail="Tokens already issued for this invoice.",
)
# set this invoice as issued
await ledger.crud.update_lightning_invoice(
db=ledger.db, hash=payment_hash, issued=True
)
status: PaymentStatus = await check_transaction_status(
cashu.wallet, payment_hash
)
try:
total_requested = sum([bm.amount for bm in data.outputs])
if total_requested > invoice.amount:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail=f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}",
)
if not status.paid:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid."
)
promises = await ledger._generate_promises(B_s=data.outputs, keyset=keyset)
return PostMintResponse(promises=promises)
except (Exception, HTTPException) as e:
logger.debug(f"Cashu: /melt {str(e) or getattr(e, 'detail')}")
# unset issued flag because something went wrong
await ledger.crud.update_lightning_invoice(
db=ledger.db, hash=payment_hash, issued=False
)
raise HTTPException(
status_code=getattr(e, "status_code")
or HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(e) or getattr(e, "detail"),
)
else:
# only used for testing when LIGHTNING=false
promises = await ledger._generate_promises(B_s=data.outputs, keyset=keyset)
return PostMintResponse(promises=promises)
@cashu_ext.post("/api/v1/{cashu_id}/melt")
async def melt_coins(
payload: PostMeltRequest, cashu_id: str = Query(None)
) -> GetMeltResponse:
"""Invalidates proofs and pays a Lightning invoice."""
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
proofs = payload.proofs
invoice = payload.pr
# !!!!!!! MAKE SURE THAT PROOFS ARE ONLY FROM THIS CASHU KEYSET ID
# THIS IS NECESSARY BECAUSE THE CASHU BACKEND WILL ACCEPT ANY VALID
# TOKENS
assert all([p.id == cashu.keyset_id for p in proofs]), HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
detail="Error: Tokens are from another mint.",
)
# set proofs as pending
await ledger._set_proofs_pending(proofs)
try:
await ledger._verify_proofs(proofs)
total_provided = sum([p["amount"] for p in proofs])
invoice_obj = bolt11.decode(invoice)
amount = math.ceil(invoice_obj.amount_msat / 1000)
internal_checking_id = await check_internal(invoice_obj.payment_hash)
if not internal_checking_id:
fees_msat = fee_reserve(invoice_obj.amount_msat)
else:
fees_msat = 0
assert total_provided >= amount + math.ceil(fees_msat / 1000), Exception(
f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)."
)
logger.debug(f"Cashu: Initiating payment of {total_provided} sats")
try:
await pay_invoice(
wallet_id=cashu.wallet,
payment_request=invoice,
description="Pay cashu invoice",
extra={"tag": "cashu", "cashu_name": cashu.name},
)
except Exception as e:
logger.debug(f"Cashu error paying invoice {invoice_obj.payment_hash}: {e}")
raise e
finally:
logger.debug(
f"Cashu: Wallet {cashu.wallet} checking PaymentStatus of {invoice_obj.payment_hash}"
)
status: PaymentStatus = await check_transaction_status(
cashu.wallet, invoice_obj.payment_hash
)
if status.paid is True:
logger.debug(
f"Cashu: Payment successful, invalidating proofs for {invoice_obj.payment_hash}"
)
await ledger._invalidate_proofs(proofs)
else:
logger.debug(f"Cashu: Payment failed for {invoice_obj.payment_hash}")
except Exception as e:
logger.debug(f"Cashu: Exception: {str(e)}")
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Cashu: {str(e)}",
)
finally:
logger.debug("Cashu: Unset pending")
# delete proofs from pending list
await ledger._unset_proofs_pending(proofs)
return GetMeltResponse(paid=status.paid, preimage=status.preimage)
@cashu_ext.post("/api/v1/{cashu_id}/check")
async def check_spendable(
payload: CheckSpendableRequest, cashu_id: str = Query(None)
) -> Dict[int, bool]:
"""Check whether a secret has been spent already or not."""
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
spendableList = await ledger.check_spendable(payload.proofs)
return CheckSpendableResponse(spendable=spendableList)
@cashu_ext.post("/api/v1/{cashu_id}/checkfees")
async def check_fees(
payload: CheckFeesRequest, cashu_id: str = Query(None)
) -> CheckFeesResponse:
"""
Responds with the fees necessary to pay a Lightning invoice.
Used by wallets for figuring out the fees they need to supply.
This is can be useful for checking whether an invoice is internal (Cashu-to-Cashu).
"""
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
invoice_obj = bolt11.decode(payload.pr)
internal_checking_id = await check_internal(invoice_obj.payment_hash)
if not internal_checking_id:
fees_msat = fee_reserve(invoice_obj.amount_msat)
else:
fees_msat = 0
return CheckFeesResponse(fee=math.ceil(fees_msat / 1000))
@cashu_ext.post("/api/v1/{cashu_id}/split")
async def split(
payload: PostSplitRequest, cashu_id: str = Query(None)
) -> PostSplitResponse:
"""
Requetst a set of tokens with amount "total" to be split into two
newly minted sets with amount "split" and "total-split".
"""
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
proofs = payload.proofs
# !!!!!!! MAKE SURE THAT PROOFS ARE ONLY FROM THIS CASHU KEYSET ID
# THIS IS NECESSARY BECAUSE THE CASHU BACKEND WILL ACCEPT ANY VALID
# TOKENS
if not all([p.id == cashu.keyset_id for p in proofs]):
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
detail="Error: Tokens are from another mint.",
)
amount = payload.amount
outputs = payload.outputs
assert outputs, Exception("no outputs provided.")
split_return = None
try:
keyset = ledger.keysets.keysets[cashu.keyset_id]
split_return = await ledger.split(proofs, amount, outputs, keyset)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(exc),
)
if not split_return:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="there was an error with the split",
)
frst_promises, scnd_promises = split_return
resp = PostSplitResponse(fst=frst_promises, snd=scnd_promises)
return resp

View file

@ -1,11 +0,0 @@
# Deezy: Home for Lightning Liquidity
Swap lightning bitcoin for on-chain bitcoin to get inbound liquidity. Or get an on-chain deposit address for your lightning address.
* [Website](https://deezy.io)
* [Lightning Node](https://amboss.space/node/024bfaf0cabe7f874fd33ebf7c6f4e5385971fc504ef3f492432e9e3ec77e1b5cf)
* [Documentation](https://docs.deezy.io)
* [Discord](https://discord.gg/nEBbrUAvPy)
# Usage
This extension lets you swap lightning btc for on-chain btc and vice versa.
* Swap Lightning -> BTC to get inbound liquidity
* Swap BTC -> Lightning to generate an on-chain deposit address for your lightning address

View file

@ -1,25 +0,0 @@
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_deezy")
deezy_ext: APIRouter = APIRouter(prefix="/deezy", tags=["deezy"])
deezy_static_files = [
{
"path": "/deezy/static",
"app": StaticFiles(directory="lnbits/extensions/deezy/static"),
"name": "deezy_static",
}
]
def deezy_renderer():
return template_renderer(["lnbits/extensions/deezy/templates"])
from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403

View file

@ -1,6 +0,0 @@
{
"name": "Deezy",
"short_description": "LN to onchain, onchain to LN swaps",
"tile": "/deezy/static/deezy.png",
"contributors": ["Uthpala"]
}

View file

@ -1,114 +0,0 @@
from typing import List, Optional
from . import db
from .models import BtcToLnSwap, LnToBtcSwap, Token, UpdateLnToBtcSwap
async def get_ln_to_btc() -> List[LnToBtcSwap]:
rows = await db.fetchall(
"SELECT * FROM deezy.ln_to_btc_swap ORDER BY created_at DESC",
)
return [LnToBtcSwap(**row) for row in rows]
async def get_btc_to_ln() -> List[BtcToLnSwap]:
rows = await db.fetchall(
"SELECT * FROM deezy.btc_to_ln_swap ORDER BY created_at DESC",
)
return [BtcToLnSwap(**row) for row in rows]
async def get_token() -> Optional[Token]:
row = await db.fetchone(
"SELECT * FROM deezy.token ORDER BY created_at DESC",
)
return Token(**row) if row else None
async def save_token(
data: Token,
) -> Token:
await db.execute(
"""
INSERT INTO deezy.token (
deezy_token
)
VALUES (?)
""",
(data.deezy_token,),
)
return data
async def save_ln_to_btc(
data: LnToBtcSwap,
) -> LnToBtcSwap:
return await db.execute(
"""
INSERT INTO deezy.ln_to_btc_swap (
amount_sats,
on_chain_address,
on_chain_sats_per_vbyte,
bolt11_invoice,
fee_sats,
txid,
tx_hex
)
VALUES (?,?,?,?,?,?,?)
""",
(
data.amount_sats,
data.on_chain_address,
data.on_chain_sats_per_vbyte,
data.bolt11_invoice,
data.fee_sats,
data.txid,
data.tx_hex,
),
)
async def update_ln_to_btc(data: UpdateLnToBtcSwap) -> str:
await db.execute(
"""
UPDATE deezy.ln_to_btc_swap
SET txid = ?, tx_hex = ?
WHERE bolt11_invoice = ?
""",
(data.txid, data.tx_hex, data.bolt11_invoice),
)
return data.txid
async def save_btc_to_ln(
data: BtcToLnSwap,
) -> BtcToLnSwap:
return await db.execute(
"""
INSERT INTO deezy.btc_to_ln_swap (
ln_address,
on_chain_address,
secret_access_key,
commitment,
signature
)
VALUES (?,?,?,?,?)
""",
(
data.ln_address,
data.on_chain_address,
data.secret_access_key,
data.commitment,
data.signature,
),
)

View file

@ -1,37 +0,0 @@
async def m001_initial(db):
await db.execute(
f"""
CREATE TABLE deezy.ln_to_btc_swap (
id TEXT PRIMARY KEY,
amount_sats {db.big_int} NOT NULL,
on_chain_address TEXT NOT NULL,
on_chain_sats_per_vbyte INT NOT NULL,
bolt11_invoice TEXT NOT NULL,
fee_sats {db.big_int} NOT NULL,
txid TEXT NULL,
tx_hex TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
"""
)
await db.execute(
"""
CREATE TABLE deezy.btc_to_ln_swap (
id TEXT PRIMARY KEY,
ln_address TEXT NOT NULL,
on_chain_address TEXT NOT NULL,
secret_access_key TEXT NOT NULL,
commitment TEXT NOT NULL,
signature TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
"""
)
await db.execute(
"""
CREATE TABLE deezy.token (
deezy_token TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
"""
)

View file

@ -1,31 +0,0 @@
from pydantic.main import BaseModel
class Token(BaseModel):
deezy_token: str
class LnToBtcSwap(BaseModel):
amount_sats: int
on_chain_address: str
on_chain_sats_per_vbyte: int
bolt11_invoice: str
fee_sats: int
txid: str = ""
tx_hex: str = ""
created_at: str = ""
class UpdateLnToBtcSwap(BaseModel):
txid: str
tx_hex: str
bolt11_invoice: str
class BtcToLnSwap(BaseModel):
ln_address: str
on_chain_address: str
secret_access_key: str
commitment: str
signature: str
created_at: str = ""

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

View file

@ -1,253 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="About Deezy"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<img
alt=""
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNTMwLjA5IDEzNi43MyI+PGRlZnM+PHN0eWxlPi5jbHMtMXtmaWxsOiNmZmY7fS5jbHMtMntmaWxsLXJ1bGU6ZXZlbm9kZDtmaWxsOnVybCgjbGluZWFyLWdyYWRpZW50KTt9LmNscy0ze2ZpbGw6I2ZmYzkyYjt9PC9zdHlsZT48bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhci1ncmFkaWVudCIgeDE9IjUxLjY5IiB5MT0iMzEuNjciIHgyPSIxODAuMjMiIHkyPSIxMDUuMTIiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNmZmYyMWYiLz48c3RvcCBvZmZzZXQ9IjAuMjkiIHN0b3AtY29sb3I9IiNmZmNkMmQiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNmNzkyMzMiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48ZyBpZD0iTGF5ZXJfMiIgZGF0YS1uYW1lPSJMYXllciAyIj48ZyBpZD0iTGF5ZXJfMS0yIiBkYXRhLW5hbWU9IkxheWVyIDEiPjxwYXRoIGNsYXNzPSJjbHMtMSIgZD0iTTYxLjg5LDBoNTcuNTVDMTMzLjksMCwxNDUsMS40NCwxNTIuOTIsNC4zM2MxNC4yMSw1LjA1LDIzLjYsMTQuMTgsMjguNjYsMjcuNjRMMTUyLjY4LDQ2LjRsLS4yMy0uNDhjLTIuMTgtNi43NC01LjA2LTExLjU0LTguNDMtMTQuOUEyNS40MywyNS40MywwLDAsMCwxMzIsMjUuNDlsLS4yNC0yLjg5LTMuMTMsMi4xNmE1NC4xMSw1NC4xMSwwLDAsMC05LjE2LS40OEg5MC43OVY1MUw2MS44OSw3MC42OFptMTI1LDU0LjgxQTEyNC43NiwxMjQuNzYsMCwwLDEsMTg3LjYsNjhhMTA4LjM4LDEwOC4zOCwwLDAsMS01LjMsMzQuNjJjLTMuMzcsMTEuMy05LjM5LDE5LjQ3LTE3LjU4LDI0Ljc2YTQ2LjE4LDQ2LjE4LDAsMCwxLTE3LjA5LDYuNDljLTYsMS4yLTE1LjQxLDEuNjgtMjguMTksMS42OEg2MS44OVY5OS4yOWwyOC45LTE0LjQzdjI2LjY5aDExLjU2bC4yNCwyLjE2LDMuMzctMi4xNmgxMy40OGMxMi43OCwwLDIxLjQ0LTIuODksMjYuMjYtOC40MiwzLjEzLTMuNiw1LjU0LTguNDEsNy4yMi0xNC45YTU0LjI4LDU0LjI4LDAsMCwwLDIuNDEtMTEuM1oiLz48cG9seWdvbiBjbGFzcz0iY2xzLTIiIHBvaW50cz0iMCAxMjIuMTMgMTI1LjcxIDM1LjU4IDEyOC44NSA2Ni41OSAyMzEuOTIgMTQuNjcgMTA4LjM3IDEwMC45NyAxMDQuNzYgNjkuNzEgMCAxMjIuMTMiLz48cGF0aCBjbGFzcz0iY2xzLTEiIGQ9Ik0yNjYuNjksMjguNjhoMTN2ODRoLTEzVjEwNHEtNy4zMiwxMC4yLTIxLDEwLjJhMjguMTQsMjguMTQsMCwwLDEtMjEuMTItOS4xOCwzMS4yMSwzMS4yMSwwLDAsMS04Ljc2LTIyLjM4LDMxLjE1LDMxLjE1LDAsMCwxLDguNzYtMjIuNDQsMjguMjMsMjguMjMsMCwwLDEsMjEuMTItOS4xMnExMy42OCwwLDIxLDEwLjA4Wk0yMzQuMTcsOTYuNDJhMTkuNTcsMTkuNTcsMCwwLDAsMjcuMTIsMCwxOC43NCwxOC43NCwwLDAsMCw1LjQtMTMuNzQsMTguNzQsMTguNzQsMCwwLDAtNS40LTEzLjc0LDE5LjU3LDE5LjU3LDAsMCwwLTI3LjEyLDAsMTguNzQsMTguNzQsMCwwLDAtNS40LDEzLjc0QTE4Ljc0LDE4Ljc0LDAsMCwwLDIzNC4xNyw5Ni40MloiLz48cGF0aCBjbGFzcz0iY2xzLTMiIGQ9Ik0zMDIsODguMmExNi40OCwxNi40OCwwLDAsMCw2LjYsMTAuNSwyMS4yMiwyMS4yMiwwLDAsMCwxMi42LDMuNjZxMTAuMzIsMCwxNS40OC03LjQ0bDEwLjY4LDYuMjRxLTguODgsMTMuMDgtMjYuMjgsMTMuMDgtMTQuNjQsMC0yMy42NC04Ljk0dC05LTIyLjYycTAtMTMuNDQsOC44OC0yMi41dDIyLjgtOS4wNnExMy4yLDAsMjEuNjYsOS4yNGEzMiwzMiwwLDAsMSw4LjQ2LDIyLjQ0LDQwLjA5LDQwLjA5LDAsMCwxLS40OCw1LjRabS0uMTItMTAuNTZoMzUuMjhxLTEuMzItNy4zMi02LjA2LTExQTE3LjQ1LDE3LjQ1LDAsMCwwLDMyMCw2Mi44OGExOC4yMywxOC4yMywwLDAsMC0xMiw0QTE3Ljg2LDE3Ljg2LDAsMCwwLDMwMS44NSw3Ny42NFoiLz48cGF0aCBjbGFzcz0iY2xzLTEiIGQ9Ik0zNjguNDUsODguMmExNi40OCwxNi40OCwwLDAsMCw2LjYsMTAuNSwyMS4yMiwyMS4yMiwwLDAsMCwxMi42LDMuNjZxMTAuMzIsMCwxNS40OC03LjQ0bDEwLjY4LDYuMjRxLTguODgsMTMuMDgtMjYuMjgsMTMuMDgtMTQuNjQsMC0yMy42NC04Ljk0dC05LTIyLjYycTAtMTMuNDQsOC44OC0yMi41dDIyLjgtOS4wNnExMy4yLDAsMjEuNjYsOS4yNGEzMiwzMiwwLDAsMSw4LjQ2LDIyLjQ0LDQwLjA5LDQwLjA5LDAsMCwxLS40OCw1LjRabS0uMTItMTAuNTZoMzUuMjhxLTEuMzItNy4zMi02LjA2LTExYTE3LjQ1LDE3LjQ1LDAsMCwwLTExLjEtMy43MiwxOC4yMywxOC4yMywwLDAsMC0xMiw0QTE3Ljg2LDE3Ljg2LDAsMCwwLDM2OC4zMyw3Ny42NFoiLz48cGF0aCBjbGFzcz0iY2xzLTMiIGQ9Ik00MzcuNTgsMTAwLjQ0aDI5LjE2djEyLjI0SDQxOS45M1YxMDRMNDQ4LDY0LjkySDQyMS4xM1Y1Mi42OGg0NC4zOXY4LjYzWiIvPjxwYXRoIGNsYXNzPSJjbHMtMSIgZD0iTTUxNi4yOSw1Mi42OGgxMy44bC0yMyw2MS45MnEtOC42NCwyMy4yOC0yOS4yOCwyMi4wOFYxMjQuNTZxNi4xMi4zNiw5Ljg0LTIuNTh0Ni4xMi05LjE4bC42LTEuMkw0NjguODksNTIuNjhoMTQuMTZsMTcuODksNDMuNTVaIi8+PC9nPjwvZz48L3N2Zz4="
height="40"
class="d-inline-block align-top my-2"
/>
<h5 class="text-subtitle1 q-my-none">
Deezy.io: Do onchain to offchain and vice-versa swaps
</h5>
<p>
Link :
<a class="text-light-blue" target="_blank" href="https://deezy.io/">
https://deezy.io/
</a>
</p>
<p>
<a class="text-light-blue" target="_blank" href="https://docs.deezy.io/"
>API DOCS</a
>
</p>
<p>
<small
>Created by,
<a
class="text-light-blue"
target="_blank"
href="https://twitter.com/Uthpala_419"
>Uthpala</a
></small
>
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item
group="swap-ln-to-btc"
dense
expand-separator
label="Swap (LIGHTNING TO BTC)"
:content-inset-level="0.5"
>
<q-expansion-item group="ln-to-btc" dense expand-separator label="GET Info">
<q-card>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">
Get the current info about the swap service for converting LN btc to
on-chain BTC.
</h5>
<code class="text-light-blue">
<span class="text-white">GET (mainnet)</span>
https://api.deezy.io/v1/swap/info
</code>
<br />
<code class="text-light-blue">
<span class="text-white">GET (testnet)</span>
https://api-testnet.deezy.io/v1/swap/info
</code>
<h6 class="text-caption q-mt-sm q-mb-none">Response</h6>
<pre>
{
"liquidity_fee_ppm": 2000,
"on_chain_bytes_estimate": 300,
"max_swap_amount_sats": 100000000,
"min_swap_amount_sats": 100000,
"available": true
}
</pre>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="ln-to-btc"
dense
expand-separator
label="POST New (LN to BTC) Swap"
>
<q-card>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">
Initiate a new swap to send lightning btc in exchange for on-chain
btc
</h5>
<code class="text-light-blue">
<span class="text-white">POST (mainnet)</span>
https://api.deezy.io/v1/swap
</code>
<br />
<code class="text-light-blue">
<span class="text-white">POST (testnet)</span>
https://api-testnet.deezy.io/v1/swap
</code>
<h6 class="text-caption q-mt-sm q-mb-none">Payload</h6>
<pre>
{
"amount_sats": 500000,
"on_chain_address": "tb1qrcdhlm0m...",
"on_chain_sats_per_vbyte": 2
}
</pre>
<h6 class="text-caption q-mt-sm q-mb-none">Response</h6>
<pre>
{
"bolt11_invoice": "lntb603u1p3vmxj7p...",
"fee_sats": 600
}
</pre>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="ln-to-btc"
dense
expand-separator
label="GET Lookup (LN to BTC) Swap"
>
<q-card>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">
Lookup the on-chain transaction information for an existing swap
</h5>
<code class="text-light-blue">
<span class="text-white">GET (mainnet)</span>
https://api.deezy.io/v1/swap/lookup
</code>
<br />
<code class="text-light-blue">
<span class="text-white">GET (testnet)</span>
https://api-testnet.deezy.io/v1/swap/lookup
</code>
<h6 class="text-caption q-mt-sm q-mb-none">Query Parameter</h6>
<pre>
"bolt11_invoice": "lntb603u1p3vmxj7pp54...",
</pre>
<h6 class="text-caption q-mt-sm q-mb-none">Response</h6>
<pre>
{
"on_chain_txid": "string",
"tx_hex": "string"
}
</pre>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
<q-expansion-item
group="swap-btc-to-ln"
dense
expand-separator
label="Swap (BTC TO LIGHTNING)"
:content-inset-level="0.5"
>
<q-expansion-item
group="btc-to-ln"
dense
expand-separator
label="POST New On-Chain Deposit Address"
>
<q-card>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">
Generate an on-chain deposit address for your lnurl or lightning
address.
</h5>
<code class="text-light-blue">
<span class="text-white">POST (mainnet)</span>
https://api.deezy.io/v1/source
</code>
<br />
<code class="text-light-blue">
<span class="text-white">POST (testnet)</span>
https://api-testnet.deezy.io/v1/source
</code>
<h6 class="text-caption q-mt-sm q-mb-none">Payload</h6>
<pre>
{
"lnurl_or_lnaddress": "LNURL1DP68GURN8GHJ...",
"secret_access_key": "b3c6056d2845867fa7..",
"webhook_url": "https://your.website.com/dee.."
}
</pre>
<h6 class="text-caption q-mt-sm q-mb-none">Response</h6>
<pre>
{
"address": "bc1qkceyc5...",
"secret_access_key": "b3c6056d28458...",
"commitment": "for any satoshis sent to bc1..",
"signature": "d69j6aj1ssz5egmsr..",
"webhook_url": "https://your.website.com/deez.."
}
</pre>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="btc-to-ln"
dense
expand-separator
label="GET Lookup (BTC to LN) Swaps"
>
<q-card>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">
Lookup (BTC to LN) swaps
</h5>
<code class="text-light-blue">
<span class="text-white">GET (mainnet)</span>
https://api.deezy.io/v1/source/lookup
</code>
<br />
<code class="text-light-blue">
<span class="text-white">GET (testnet)</span>
https://api-testnet.deezy.io/v1/source/lookup
</code>
<h6 class="text-caption q-mt-sm q-mb-none">Response</h6>
<pre>
{
"swaps": [
{
"lnurl_or_lnaddress": "string",
"deposit_address": "string",
"utxo_key": "string",
"deposit_amount_sats": 0,
"target_payout_amount_sats": 0,
"paid_amount_sats": 0,
"deezy_fee_sats": 0,
"status": "string"
}
],
"total_sent_sats": 0,
"total_received_sats": 0,
"total_pending_payout_sats": 0,
"total_deezy_fees_sats": 0
}
</pre>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
</q-expansion-item>

View file

@ -1,588 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-mt-none q-mb-md">Deezy</h5>
<p class="text-subtitle2 q-mt-none q-mb-md">
An access token is required to use the swap service. Email
support@deezy.io or contact @dannydeezy on telegram to get one.
</p>
<div>
<div class="flex justify-between items-center">
<span>Deezy token </span>
<q-btn
type="button"
@click="showDeezyTokenForm = !showDeezyTokenForm"
>Add or Update token</q-btn
>
</div>
<p v-if="storedDeezyToken" v-text="storedDeezyToken"></p>
</div>
<q-form
v-if="showDeezyTokenForm"
@submit="storeDeezyToken"
class="q-gutter-md q-mt-lg"
>
<q-input
filled
dense
emit-value
:placeholder="storedDeezyToken"
v-model.trim="deezyToken"
label="Deezy Token"
type="text"
></q-input>
<q-btn color="grey" type="submit" label="Store Deezy Token"></q-btn>
</q-form>
<q-separator class="q-my-lg"></q-separator>
<q-card>
<q-card-section>
<q-btn
label="SWAP (LIGHTNING -> BTC)"
unelevated
color="primary"
@click="showLnToBtcForm"
:disabled="!storedDeezyToken"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
Send lightning btc and receive on-chain btc
</q-tooltip>
</q-btn>
<q-btn
label="SWAP (BTC -> LIGHTNING)"
unelevated
color="primary"
@click="swapBtcToLn.show = true; swapLnToBtc.show = false;"
:disabled="!storedDeezyToken"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
Send on-chain btc and receive via lightning
</q-tooltip>
</q-btn>
</q-card-section>
</q-card>
<div
v-show="swapLnToBtc.show"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<h6 class="q-mt-none">LIGHTNING BTC -> BTC</h6>
<q-form @submit="sendLnToBtc" class="q-gutter-md">
<q-input
filled
dense
emit-value
v-model.trim="swapLnToBtc.data.amount"
label="Amount (sats)"
type="number"
></q-input>
<q-input
filled
dense
emit-value
v-model.trim="swapLnToBtc.data.on_chain_address"
type="string"
label="Onchain address to receive funds"
></q-input>
<q-input
filled
dense
emit-value
v-model.trim="swapLnToBtc.data.on_chain_sats_per_vbyte"
label="On chain fee rate (sats/vbyte)"
min="1"
type="number"
:hint="swapLnToBtc.suggested_fees && `Economy Fee - ${swapLnToBtc.suggested_fees?.economyFee} | Half an hour fee - ${swapLnToBtc.suggested_fees?.halfHourFee} | Fastest fee - ${swapLnToBtc.suggested_fees?.fastestFee}`"
>
</q-input>
<q-btn
unelevated
color="primary"
type="submit"
label="Create Swap"
></q-btn>
<q-btn flat color="grey" class="q-ml-auto" @click="resetSwapLnToBtc"
>Cancel</q-btn
>
</q-form>
<q-dialog v-model="swapLnToBtc.showInvoice" persistent>
<q-card flat bordered class="my-card">
<q-card-section>
<div class="flex justify-between">
<div class="text-h6">Pay invoice to complete swap</div>
<q-btn flat v-close-popup>
<q-icon name="close" />
</q-btn>
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<qrcode
:value="swapLnToBtc.response"
:options="{width: 360}"
class="rounded-borders"
></qrcode>
</q-card-section>
<q-card-section>
<q-btn
outline
@click="copyLnInvoice"
label="Copy"
color="primary"
></q-btn>
<q-input
v-model="swapLnToBtc.response"
type="textarea"
readonly
@click="$event.target.select()"
/>
</q-card-section>
</q-card>
</q-dialog>
</div>
<div
v-show="swapBtcToLn.show"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<h6 class="q-mt-none">BTC -> LIGHTNING BTC</h6>
<q-form @submit="sendBtcToLn" class="q-gutter-md">
<q-input
filled
dense
emit-value
v-model.trim="swapBtcToLn.data.lnurl_or_lnaddress"
label="Lnurl or Lightning Address"
type="string"
></q-input>
<q-btn
unelevated
color="primary"
type="submit"
label="Generate Onchain Address"
></q-btn>
<q-btn flat color="grey" class="q-ml-auto" @click="resetSwapBtcToLn"
>Cancel</q-btn
>
</q-form>
<q-dialog v-model="swapBtcToLn.showDetails" persistent>
<q-card flat bordered class="my-card">
<q-card-section>
<div class="flex justify-between">
<div class="text-h6">Onchain Address</div>
<q-btn flat v-close-popup>
<q-icon name="close" />
</q-btn>
</div>
</q-card-section>
<q-card-section>
<q-input
v-model="swapBtcToLn.response.address"
type="textarea"
readonly
@click="$event.target.select()"
/>
</q-card-section>
<q-card-section>
<q-btn
outline
@click="copyBtcToLnBtcAddress"
label="Copy Address"
color="primary"
></q-btn>
</q-card-section>
<q-card-section>
<q-input
v-model="swapBtcToLn.response.commitment"
type="textarea"
readonly
@click="$event.target.select()"
/>
</q-card-section>
</q-card>
</q-dialog>
</div>
</q-card-section>
</q-card>
{% raw %}
<q-dialog v-model="swapLnToBtc.invoicePaid">
<q-card class="bg-teal text-white" style="width: 400px">
<q-card-section>
<div class="text-h6">Success Bitcoin is on its way</div>
</q-card-section>
<q-card-section class="q-pt-none">
Onchain tx id {{ swapLnToBtc.onchainTxId }}
</q-card-section>
<q-card-actions align="right" class="bg-white text-teal">
<q-btn flat label="OK" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
{% endraw %}
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} Deezy extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "deezy/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<div class="q-pa-md full-width">
<q-table
title="Swaps Lightning -> BTC"
:data="rowsLnToBtc"
:columns="columnsLnToBtc"
row-key="name"
/>
</div>
<div class="q-pa-md full-width">
<q-table
title="Swaps BTC -> Lightning"
:data="rowsBtcToLn"
:columns="columnsBtcToLn"
row-key="name"
/>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
columnsLnToBtc: [
{
name: 'amount_sats',
label: 'Amount Sats',
align: 'left',
field: 'amount_sats',
sortable: true
},
{
name: 'on_chain_address',
align: 'left',
label: 'On chain address',
field: 'on_chain_address'
},
{
name: 'on_chain_sats_per_vbyte',
align: 'left',
label: 'Onchin sats per vbyte',
field: 'on_chain_sats_per_vbyte',
sortable: true
},
{
name: 'fee_sats',
label: 'Fee sats',
align: 'left',
field: 'fee_sats'
},
{name: 'txid', label: 'Tx Id', align: 'left', field: 'txid'},
{name: 'tx_hex', label: 'Tx Hex', align: 'left', field: 'tx_hex'},
{
name: 'created_at',
label: 'Created at',
align: 'left',
field: 'created_at',
sortable: true,
sort: true
}
],
rowsLnToBtc: [],
columnsBtcToLn: [
{
name: 'ln_address',
align: 'left',
label: 'Ln Address or Invoice',
field: 'ln_address'
},
{
name: 'on_chain_address',
align: 'left',
label: 'Onchain Address',
field: 'on_chain_address'
},
{
name: 'secret_access_key',
align: 'left',
label: 'Secret Access Key',
field: 'secret_access_key'
},
{
name: 'commitment',
align: 'left',
label: 'Commitment',
field: 'commitment'
},
{
name: 'signature',
align: 'left',
label: 'Signature',
field: 'signature'
},
{
name: 'created_at',
label: 'Created at',
field: 'created_at',
align: 'left',
sortable: true,
sort: true
}
],
rowsBtcToLn: [],
showDeezyTokenForm: false,
storedDeezyToken: null,
deezyToken: null,
lightning_btc: '',
tools: [],
swapLnToBtc: {
show: false,
showInvoice: false,
data: {
on_chain_sats_per_vbyte: 1
},
suggested_fees: null,
response: null,
invoicePaid: false,
onchainTxId: null
},
swapBtcToLn: {
show: false,
showDetails: false,
data: {},
response: {}
}
}
},
created: async function () {
this.getToken()
this.getLnToBtc()
this.getBtcToLn()
},
methods: {
updateLnToBtc(payload) {
var self = this
return axios
.post('/deezy/api/v1/update-ln-to-btc', {
...payload
})
.then(function (response) {
console.log('btc to ln is update', response)
})
.catch(function (error) {
console.log(error)
})
},
getToken() {
var self = this
axios({
method: 'GET',
url: '/deezy/api/v1/token'
}).then(function (response) {
self.storedDeezyToken = response.data.deezy_token
if (!self.storeDeezyToken) {
showDeezyTokenForm = true
}
})
},
getLnToBtc() {
var self = this
axios.get('/deezy/api/v1/ln-to-btc').then(function (response) {
if (response.data.length) {
self.rowsLnToBtc = response.data
}
})
},
getBtcToLn() {
var self = this
axios.get('/deezy/api/v1/btc-to-ln').then(function (response) {
if (response.data.length) {
self.rowsBtcToLn = response.data
}
})
},
showLnToBtcForm() {
if (!this.swapLnToBtc.show) {
this.getSuggestedOnChainFees()
}
this.swapLnToBtc.show = true
this.swapBtcToLn.show = false
},
getSuggestedOnChainFees() {
axios
.get('https://mempool.space/api/v1/fees/recommended')
.then(result => {
this.swapLnToBtc.suggested_fees = result.data
})
},
checkIfInvoiceIsPaid() {
if (this.swapLnToBtc.response && !this.swapLnToBtc.invoicePaid) {
var self = this
let interval = setInterval(() => {
axios
.get(
`https://api.deezy.io/v1/swap/lookup?bolt11_invoice=${self.swapLnToBtc.response}`
)
.then(async function (response) {
if (response.data.on_chain_txid) {
self.swapLnToBtc = {
...self.swapLnToBtc,
invoicePaid: true,
onchainTxId: response.data.on_chain_txid
}
self
.updateLnToBtc({
txid: response.data.on_chain_txid,
tx_hex: response.data.tx_hex,
bolt11_invoice: self.swapLnToBtc.response
})
.then(() => {
self.getLnToBtc()
})
clearInterval(interval)
}
})
}, 4000)
}
},
copyLnInvoice() {
Quasar.utils.copyToClipboard(this.swapLnToBtc.response)
},
copyBtcToLnBtcAddress() {
Quasar.utils.copyToClipboard(this.swapBtcToLn.response.address)
},
sendLnToBtc() {
var self = this
axios
.post(
'https://api.deezy.io/v1/swap',
{
amount_sats: parseInt(self.swapLnToBtc.data.amount),
on_chain_address: self.swapLnToBtc.data.on_chain_address,
on_chain_sats_per_vbyte: parseInt(
self.swapLnToBtc.data.on_chain_sats_per_vbyte
)
},
{
headers: {
'x-api-token': self.storedDeezyToken
}
}
)
.then(function (response) {
self.swapLnToBtc = {
...self.swapLnToBtc,
showInvoice: true,
response: response.data.bolt11_invoice
}
const payload = {
amount_sats: parseInt(self.swapLnToBtc.data.amount),
on_chain_address: self.swapLnToBtc.data.on_chain_address,
on_chain_sats_per_vbyte:
self.swapLnToBtc.data.on_chain_sats_per_vbyte,
bolt11_invoice: response.data.bolt11_invoice,
fee_sats: response.data.fee_sats
}
self.storeLnToBtc(payload)
self.checkIfInvoiceIsPaid()
})
.catch(function (error) {
console.log(error)
})
},
sendBtcToLn() {
var self = this
axios
.post(
'https://api.deezy.io/v1/source',
{
lnurl_or_lnaddress: self.swapBtcToLn.data.lnurl_or_lnaddress
},
{
headers: {
'x-api-token': self.storedDeezyToken
}
}
)
.then(function (response) {
self.swapBtcToLn = {
...self.swapBtcToLn,
response: response.data,
showDetails: true
}
const payload = {
ln_address: self.swapBtcToLn.data.lnurl_or_lnaddress,
on_chain_address: response.data.address,
secret_access_key: response.data.secret_access_key,
commitment: response.data.commitment,
signature: response.data.signature
}
self.storeBtcToLn(payload)
})
.catch(function (error) {
console.log(error)
})
},
storeBtcToLn(payload) {
var self = this
axios
.post('/deezy/api/v1/store-btc-to-ln', {
...payload
})
.then(function (response) {
console.log('btc to ln is stored', response)
})
.catch(function (error) {
console.log(error)
})
},
storeLnToBtc(payload) {
var self = this
axios
.post('/deezy/api/v1/store-ln-to-btc', {
...payload
})
.then(function (response) {
console.log('ln to btc is stored', response)
})
.catch(function (error) {
console.log(error)
})
},
storeDeezyToken() {
var self = this
axios
.post('/deezy/api/v1/store-token', {
deezy_token: self.deezyToken
})
.then(function (response) {
self.storedDeezyToken = response.data
self.showDeezyTokenForm = false
})
.catch(function (error) {
console.log(error)
})
},
resetSwapBtcToLn() {
this.swapBtcToLn = {
...this.swapBtcToLn,
data: {}
}
},
resetSwapLnToBtc() {
this.swapLnToBtc = {
...this.swapLnToBtc,
data: {}
}
}
}
})
</script>
{% endblock %}

View file

@ -1,21 +0,0 @@
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import deezy_ext, deezy_renderer
templates = Jinja2Templates(directory="templates")
@deezy_ext.get("/", response_class=HTMLResponse)
async def index(
request: Request,
user: User = Depends(check_user_exists), # type: ignore
):
return deezy_renderer().TemplateResponse(
"deezy/index.html", {"request": request, "user": user.dict()}
)

View file

@ -1,65 +0,0 @@
# views_api.py is for you API endpoints that could be hit by another service
# add your dependencies here
# import httpx
# (use httpx just like requests, except instead of response.ok there's only the
# response.is_error that is its inverse)
from . import deezy_ext
from .crud import (
get_btc_to_ln,
get_ln_to_btc,
get_token,
save_btc_to_ln,
save_ln_to_btc,
save_token,
update_ln_to_btc,
)
from .models import BtcToLnSwap, LnToBtcSwap, Token, UpdateLnToBtcSwap
@deezy_ext.get("/api/v1/token")
async def api_deezy_get_token():
rows = await get_token()
return rows
@deezy_ext.get("/api/v1/ln-to-btc")
async def api_deezy_get_ln_to_btc():
rows = await get_ln_to_btc()
return rows
@deezy_ext.get("/api/v1/btc-to-ln")
async def api_deezy_get_btc_to_ln():
rows = await get_btc_to_ln()
return rows
@deezy_ext.post("/api/v1/store-token")
async def api_deezy_save_toke(data: Token):
await save_token(data)
return data.deezy_token
@deezy_ext.post("/api/v1/store-ln-to-btc")
async def api_deezy_save_ln_to_btc(data: LnToBtcSwap):
response = await save_ln_to_btc(data)
return response
@deezy_ext.post("/api/v1/update-ln-to-btc")
async def api_deezy_update_ln_to_btc(data: UpdateLnToBtcSwap):
response = await update_ln_to_btc(data)
return response
@deezy_ext.post("/api/v1/store-btc-to-ln")
async def api_deezy_save_btc_to_ln(data: BtcToLnSwap):
response = await save_btc_to_ln(data)
return response

View file

@ -1,19 +0,0 @@
# Invoices
## Create invoices that you can send to your client to pay online over Lightning.
This extension allows users to create "traditional" invoices (not in the lightning sense) that contain one or more line items. Line items are denominated in a user-configurable fiat currency. Each invoice contains one or more payments up to the total of the invoice. Each invoice creates a public link that can be shared with a customer that they can use to (partially or in full) pay the invoice.
## Usage
1. Create an invoice by clicking "NEW INVOICE"\
![create new invoice](https://imgur.com/a/Dce3wrr.png)
2. Fill the options for your INVOICE
- select the wallet
- select the fiat currency the invoice will be denominated in
- select a status for the invoice (default is draft)
- enter a company name, first name, last name, email, phone & address (optional)
- add one or more line items
- enter a name & price for each line item
3. You can then use share your invoice link with your customer to receive payment\
![invoice link](https://imgur.com/a/L0JOj4T.png)

View file

@ -1,36 +0,0 @@
import asyncio
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_invoices")
invoices_static_files = [
{
"path": "/invoices/static",
"app": StaticFiles(directory="lnbits/extensions/invoices/static"),
"name": "invoices_static",
}
]
invoices_ext: APIRouter = APIRouter(prefix="/invoices", tags=["invoices"])
def invoices_renderer():
return template_renderer(["lnbits/extensions/invoices/templates"])
from .tasks import wait_for_paid_invoices
def invoices_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403

View file

@ -1,6 +0,0 @@
{
"name": "Invoices",
"short_description": "Create invoices for your clients.",
"tile": "/invoices/static/image/invoices.png",
"contributors": ["leesalminen"]
}

View file

@ -1,210 +0,0 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import (
CreateInvoiceData,
CreateInvoiceItemData,
Invoice,
InvoiceItem,
Payment,
UpdateInvoiceData,
UpdateInvoiceItemData,
)
async def get_invoice(invoice_id: str) -> Optional[Invoice]:
row = await db.fetchone(
"SELECT * FROM invoices.invoices WHERE id = ?", (invoice_id,)
)
return Invoice.from_row(row) if row else None
async def get_invoice_items(invoice_id: str) -> List[InvoiceItem]:
rows = await db.fetchall(
"SELECT * FROM invoices.invoice_items WHERE invoice_id = ?", (invoice_id,)
)
return [InvoiceItem.from_row(row) for row in rows]
async def get_invoice_item(item_id: str) -> Optional[InvoiceItem]:
row = await db.fetchone(
"SELECT * FROM invoices.invoice_items WHERE id = ?", (item_id,)
)
return InvoiceItem.from_row(row) if row else None
async def get_invoice_total(items: List[InvoiceItem]) -> int:
return sum(item.amount for item in items)
async def get_invoices(wallet_ids: Union[str, List[str]]) -> List[Invoice]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM invoices.invoices WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Invoice.from_row(row) for row in rows]
async def get_invoice_payments(invoice_id: str) -> List[Payment]:
rows = await db.fetchall(
"SELECT * FROM invoices.payments WHERE invoice_id = ?", (invoice_id,)
)
return [Payment.from_row(row) for row in rows]
async def get_invoice_payment(payment_id: str) -> Optional[Payment]:
row = await db.fetchone(
"SELECT * FROM invoices.payments WHERE id = ?", (payment_id,)
)
return Payment.from_row(row) if row else None
async def get_payments_total(payments: List[Payment]) -> int:
return sum(item.amount for item in payments)
async def create_invoice_internal(wallet_id: str, data: CreateInvoiceData) -> Invoice:
invoice_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO invoices.invoices (id, wallet, status, currency, company_name, first_name, last_name, email, phone, address)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
invoice_id,
wallet_id,
data.status,
data.currency,
data.company_name,
data.first_name,
data.last_name,
data.email,
data.phone,
data.address,
),
)
invoice = await get_invoice(invoice_id)
assert invoice, "Newly created invoice couldn't be retrieved"
return invoice
async def create_invoice_items(
invoice_id: str, data: List[CreateInvoiceItemData]
) -> List[InvoiceItem]:
for item in data:
item_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO invoices.invoice_items (id, invoice_id, description, amount)
VALUES (?, ?, ?, ?)
""",
(
item_id,
invoice_id,
item.description,
int(item.amount * 100),
),
)
invoice_items = await get_invoice_items(invoice_id)
return invoice_items
async def update_invoice_internal(
wallet_id: str, data: Union[UpdateInvoiceData, Invoice]
) -> Invoice:
await db.execute(
"""
UPDATE invoices.invoices
SET wallet = ?, currency = ?, status = ?, company_name = ?, first_name = ?, last_name = ?, email = ?, phone = ?, address = ?
WHERE id = ?
""",
(
wallet_id,
data.currency,
data.status,
data.company_name,
data.first_name,
data.last_name,
data.email,
data.phone,
data.address,
data.id,
),
)
invoice = await get_invoice(data.id)
assert invoice, "Newly updated invoice couldn't be retrieved"
return invoice
async def update_invoice_items(
invoice_id: str, data: List[UpdateInvoiceItemData]
) -> List[InvoiceItem]:
updated_items = []
for item in data:
if item.id:
updated_items.append(item.id)
await db.execute(
"""
UPDATE invoices.invoice_items
SET description = ?, amount = ?
WHERE id = ?
""",
(item.description, int(item.amount * 100), item.id),
)
placeholders = ",".join("?" for _ in range(len(updated_items)))
if not placeholders:
placeholders = "?"
updated_items = ["skip"]
await db.execute(
f"""
DELETE FROM invoices.invoice_items
WHERE invoice_id = ?
AND id NOT IN ({placeholders})
""",
(
invoice_id,
*tuple(updated_items),
),
)
for item in data:
if not item:
await create_invoice_items(
invoice_id=invoice_id,
data=[CreateInvoiceItemData(description=item.description)],
)
invoice_items = await get_invoice_items(invoice_id)
return invoice_items
async def create_invoice_payment(invoice_id: str, amount: int) -> Payment:
payment_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO invoices.payments (id, invoice_id, amount)
VALUES (?, ?, ?)
""",
(
payment_id,
invoice_id,
amount,
),
)
payment = await get_invoice_payment(payment_id)
assert payment, "Newly created payment couldn't be retrieved"
return payment

View file

@ -1,55 +0,0 @@
async def m001_initial_invoices(db):
# STATUS COLUMN OPTIONS: 'draft', 'open', 'paid', 'canceled'
await db.execute(
f"""
CREATE TABLE invoices.invoices (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
currency TEXT NOT NULL,
company_name TEXT DEFAULT NULL,
first_name TEXT DEFAULT NULL,
last_name TEXT DEFAULT NULL,
email TEXT DEFAULT NULL,
phone TEXT DEFAULT NULL,
address TEXT DEFAULT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
await db.execute(
f"""
CREATE TABLE invoices.invoice_items (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
description TEXT NOT NULL,
amount INTEGER NOT NULL,
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
);
"""
)
await db.execute(
f"""
CREATE TABLE invoices.payments (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
amount {db.big_int} NOT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
);
"""
)

View file

@ -1,104 +0,0 @@
from enum import Enum
from sqlite3 import Row
from typing import List, Optional
from fastapi import Query
from pydantic import BaseModel
class InvoiceStatusEnum(str, Enum):
draft = "draft"
open = "open"
paid = "paid"
canceled = "canceled"
class CreateInvoiceItemData(BaseModel):
description: str
amount: float = Query(..., ge=0.01)
class CreateInvoiceData(BaseModel):
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
currency: str
company_name: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
email: Optional[str]
phone: Optional[str]
address: Optional[str]
items: List[CreateInvoiceItemData]
class Config:
use_enum_values = True
class UpdateInvoiceItemData(BaseModel):
id: Optional[str]
description: str
amount: float = Query(..., ge=0.01)
class UpdateInvoiceData(BaseModel):
id: str
wallet: str
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
currency: str
company_name: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
email: Optional[str]
phone: Optional[str]
address: Optional[str]
items: List[UpdateInvoiceItemData]
class Invoice(BaseModel):
id: str
wallet: str
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
currency: str
company_name: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
email: Optional[str]
phone: Optional[str]
address: Optional[str]
time: int
class Config:
use_enum_values = True
@classmethod
def from_row(cls, row: Row) -> "Invoice":
return cls(**dict(row))
class InvoiceItem(BaseModel):
id: str
invoice_id: str
description: str
amount: int
class Config:
orm_mode = True
@classmethod
def from_row(cls, row: Row) -> "InvoiceItem":
return cls(**dict(row))
class Payment(BaseModel):
id: str
invoice_id: str
amount: int
time: int
@classmethod
def from_row(cls, row: Row) -> "Payment":
return cls(**dict(row))
class CreatePaymentData(BaseModel):
invoice_id: str
amount: int

View file

@ -1,65 +0,0 @@
#invoicePage>.row:first-child>.col-md-6 {
display: flex;
}
#invoicePage>.row:first-child>.col-md-6>.q-card {
flex: 1;
}
#invoicePage .clear {
margin-bottom: 25px;
}
#printQrCode {
display: none;
}
@media (min-width: 1024px) {
#invoicePage>.row:first-child>.col-md-6:first-child>div {
margin-right: 5px;
}
#invoicePage>.row:first-child>.col-md-6:nth-child(2)>div {
margin-left: 5px;
}
}
@media print {
* {
color: black !important;
}
header, button, #payButtonContainer {
display: none !important;
}
main, .q-page-container {
padding-top: 0px !important;
}
.q-card {
box-shadow: none !important;
border: 1px solid black;
}
.q-item {
padding: 5px;
}
.q-card__section {
padding: 5px;
}
#printQrCode {
display: block;
}
p {
margin-bottom: 0px !important;
}
#invoicePage .clear {
margin-bottom: 10px !important;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

View file

@ -1,52 +0,0 @@
import asyncio
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from .crud import (
create_invoice_payment,
get_invoice,
get_invoice_items,
get_invoice_payments,
get_invoice_total,
get_payments_total,
update_invoice_internal,
)
from .models import InvoiceStatusEnum
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") != "invoices":
return
invoice_id = payment.extra.get("invoice_id")
assert invoice_id
amount = payment.extra.get("famount")
assert amount
await create_invoice_payment(invoice_id=invoice_id, amount=amount)
invoice = await get_invoice(invoice_id)
assert invoice
invoice_items = await get_invoice_items(invoice_id)
invoice_total = await get_invoice_total(invoice_items)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
if payments_total >= invoice_total:
invoice.status = InvoiceStatusEnum.paid
await update_invoice_internal(invoice.wallet, invoice)
return

View file

@ -1,153 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="List Invoices">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /invoices/api/v1/invoices</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;invoice_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}invoices/api/v1/invoices -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Fetch Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/invoices/api/v1/invoice/{invoice_id}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{invoice_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span> /invoices/api/v1/invoice</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{invoice_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}invoices/api/v1/invoice -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Update Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span>
/invoices/api/v1/invoice/{invoice_id}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{invoice_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create Invoice Payment"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span>
/invoices/api/v1/invoice/{invoice_id}/payments</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{payment_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check Invoice Payment Status"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View file

@ -1,571 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New Invoice</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Invoices</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="invoices"
row-key="id"
:columns="invoicesTable.columns"
:pagination.sync="invoicesTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="edit"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="showEditModal(props.row)"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'pay/' + props.row.id"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Invoices extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "invoices/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="saveInvoice" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-select
filled
dense
emit-value
v-model="formDialog.data.currency"
:options="currencyOptions"
label="Currency *"
></q-select>
<q-select
filled
dense
emit-value
v-model="formDialog.data.status"
:options="['draft', 'open', 'paid', 'canceled']"
label="Status *"
></q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.company_name"
label="Company Name"
placeholder="LNbits Labs"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.first_name"
label="First Name"
placeholder="Satoshi"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.last_name"
label="Last Name"
placeholder="Nakamoto"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.email"
label="Email"
placeholder="satoshi@gmail.com"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.phone"
label="Phone"
placeholder="+81 (012)-345-6789"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.address"
label="Address"
placeholder="1600 Pennsylvania Ave."
type="textarea"
></q-input>
<q-list bordered separator>
<q-item
clickable
v-ripple
v-for="(item, index) in formDialog.invoiceItems"
:key="index"
>
<q-item-section>
<q-input
filled
dense
label="Item"
placeholder="Jelly Beans"
v-model="formDialog.invoiceItems[index].description"
></q-input>
</q-item-section>
<q-item-section>
<q-input
filled
dense
label="Amount"
placeholder="4.20"
v-model="formDialog.invoiceItems[index].amount"
></q-input>
</q-item-section>
<q-item-section side>
<q-btn
unelevated
dense
size="xs"
icon="delete"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="formDialog.invoiceItems.splice(index, 1)"
></q-btn>
</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-btn flat icon="add" @click="formDialog.invoiceItems.push({})">
Add Line Item
</q-btn>
</q-item>
</q-list>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
type="submit"
v-if="typeof formDialog.data.id == 'undefined'"
>Create Invoice</q-btn
>
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
type="submit"
v-if="typeof formDialog.data.id !== 'undefined'"
>Save Invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapInvoice = function (obj) {
obj.time = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
return obj
}
var mapInvoiceItems = function (obj) {
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
invoices: [],
currencyOptions: [
'USD',
'EUR',
'GBP',
'AED',
'AFN',
'ALL',
'AMD',
'ANG',
'AOA',
'ARS',
'AUD',
'AWG',
'AZN',
'BAM',
'BBD',
'BDT',
'BGN',
'BHD',
'BIF',
'BMD',
'BND',
'BOB',
'BRL',
'BSD',
'BTN',
'BWP',
'BYN',
'BZD',
'CAD',
'CDF',
'CHF',
'CLF',
'CLP',
'CNH',
'CNY',
'COP',
'CRC',
'CUC',
'CUP',
'CVE',
'CZK',
'DJF',
'DKK',
'DOP',
'DZD',
'EGP',
'ERN',
'ETB',
'EUR',
'FJD',
'FKP',
'GBP',
'GEL',
'GGP',
'GHS',
'GIP',
'GMD',
'GNF',
'GTQ',
'GYD',
'HKD',
'HNL',
'HRK',
'HTG',
'HUF',
'IDR',
'ILS',
'IMP',
'INR',
'IQD',
'IRR',
'IRT',
'ISK',
'JEP',
'JMD',
'JOD',
'JPY',
'KES',
'KGS',
'KHR',
'KMF',
'KPW',
'KRW',
'KWD',
'KYD',
'KZT',
'LAK',
'LBP',
'LKR',
'LRD',
'LSL',
'LYD',
'MAD',
'MDL',
'MGA',
'MKD',
'MMK',
'MNT',
'MOP',
'MRO',
'MUR',
'MVR',
'MWK',
'MXN',
'MYR',
'MZN',
'NAD',
'NGN',
'NIO',
'NOK',
'NPR',
'NZD',
'OMR',
'PAB',
'PEN',
'PGK',
'PHP',
'PKR',
'PLN',
'PYG',
'QAR',
'RON',
'RSD',
'RUB',
'RWF',
'SAR',
'SBD',
'SCR',
'SDG',
'SEK',
'SGD',
'SHP',
'SLL',
'SOS',
'SRD',
'SSP',
'STD',
'SVC',
'SYP',
'SZL',
'THB',
'TJS',
'TMT',
'TND',
'TOP',
'TRY',
'TTD',
'TWD',
'TZS',
'UAH',
'UGX',
'USD',
'UYU',
'UZS',
'VEF',
'VES',
'VND',
'VUV',
'WST',
'XAF',
'XAG',
'XAU',
'XCD',
'XDR',
'XOF',
'XPD',
'XPF',
'XPT',
'YER',
'ZAR',
'ZMW',
'ZWL'
],
invoicesTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'status', align: 'left', label: 'Status', field: 'status'},
{name: 'time', align: 'left', label: 'Created', field: 'time'},
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
{
name: 'currency',
align: 'left',
label: 'Currency',
field: 'currency'
},
{
name: 'company_name',
align: 'left',
label: 'Company Name',
field: 'company_name'
},
{
name: 'first_name',
align: 'left',
label: 'First Name',
field: 'first_name'
},
{
name: 'last_name',
align: 'left',
label: 'Last Name',
field: 'last_name'
},
{name: 'email', align: 'left', label: 'Email', field: 'email'},
{name: 'phone', align: 'left', label: 'Phone', field: 'phone'},
{name: 'address', align: 'left', label: 'Address', field: 'address'}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {},
invoiceItems: []
}
}
},
methods: {
closeFormDialog: function () {
this.formDialog.data = {}
this.formDialog.invoiceItems = []
},
showEditModal: function (obj) {
this.formDialog.data = obj
this.formDialog.show = true
this.getInvoice(obj.id)
},
getInvoice: function (invoice_id) {
var self = this
LNbits.api
.request('GET', '/invoices/api/v1/invoice/' + invoice_id)
.then(function (response) {
self.formDialog.invoiceItems = response.data.items.map(function (
obj
) {
return mapInvoiceItems(obj)
})
})
},
getInvoices: function () {
var self = this
LNbits.api
.request(
'GET',
'/invoices/api/v1/invoices?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.invoices = response.data.map(function (obj) {
return mapInvoice(obj)
})
})
},
saveInvoice: function () {
var data = this.formDialog.data
data.items = this.formDialog.invoiceItems
var self = this
LNbits.api
.request(
'POST',
'/invoices/api/v1/invoice' + (data.id ? '/' + data.id : ''),
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
if (!data.id) {
self.invoices.push(mapInvoice(response.data))
} else {
self.getInvoices()
}
self.formDialog.invoiceItems = []
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteTPoS: function (tposId) {
var self = this
var tpos = _.findWhere(this.tposs, {id: tposId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this TPoS?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/tpos/api/v1/tposs/' + tposId,
_.findWhere(self.g.user.wallets, {id: tpos.wallet}).adminkey
)
.then(function (response) {
self.tposs = _.reject(self.tposs, function (obj) {
return obj.id == tposId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.invoicesTable.columns, this.invoices)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getInvoices()
}
}
})
</script>
{% endblock %}

View file

@ -1,433 +0,0 @@
{% extends "public.html" %} {% block toolbar_title %} Invoice
<q-btn
flat
dense
size="md"
@click.prevent="urlDialog.show = true"
icon="share"
color="white"
></q-btn>
<q-btn
flat
dense
size="md"
@click.prevent="printInvoice()"
icon="print"
color="white"
></q-btn>
{% endblock %} {% from "macros.jinja" import window_vars with context %} {%
block page %}
<link rel="stylesheet" href="/invoices/static/css/pay.css" />
<div id="invoicePage">
<div class="row q-gutter-y-md">
<div class="col-md-6 col-sm-12 col-xs-12">
<q-card>
<q-card-section>
<p>
<b>Invoice</b>
</p>
<q-list bordered separator>
<q-item clickable v-ripple>
<q-item-section><b>ID</b></q-item-section>
<q-item-section style="word-break: break-all"
>{{ invoice_id }}</q-item-section
>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Created At</b></q-item-section>
<q-item-section
>{{ datetime.utcfromtimestamp(invoice.time).strftime('%Y-%m-%d
%H:%M') }}</q-item-section
>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Status</b></q-item-section>
<q-item-section>
<span>
<q-badge color=""> {{ invoice.status }} </q-badge>
</span>
</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Total</b></q-item-section>
<q-item-section>
{{ "{:0,.2f}".format(invoice_total / 100) }} {{ invoice.currency
}}
</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Paid</b></q-item-section>
<q-item-section>
<div class="row" style="align-items: center">
<div class="col-sm-6">
{{ "{:0,.2f}".format(payments_total / 100) }} {{
invoice.currency }}
</div>
<div class="col-sm-6" id="payButtonContainer">
{% if payments_total < invoice_total %}
<q-btn
unelevated
color="primary"
@click="formDialog.show = true"
v-if="status == 'open'"
>
Pay Invoice
</q-btn>
{% endif %}
</div>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
<div class="col-md-6 col-sm-12 col-xs-12">
<q-card>
<q-card-section>
<p>
<b>Bill To</b>
</p>
<q-list bordered separator>
<q-item clickable v-ripple>
<q-item-section><b>Company Name</b></q-item-section>
<q-item-section>{{ invoice.company_name }}</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Name</b></q-item-section>
<q-item-section
>{{ invoice.first_name }} {{ invoice.last_name
}}</q-item-section
>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Address</b></q-item-section>
<q-item-section>{{ invoice.address }}</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Email</b></q-item-section>
<q-item-section>{{ invoice.email }}</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Phone</b></q-item-section>
<q-item-section>{{ invoice.phone }}</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<div class="clear"></div>
<div class="row">
<div class="col-12 col-md">
<q-card>
<q-card-section>
<p>
<b>Items</b>
</p>
<q-list bordered separator>
{% if invoice_items %}
<q-item clickable v-ripple>
<q-item-section><b>Item</b></q-item-section>
<q-item-section side><b>Amount</b></q-item-section>
</q-item>
{% endif %} {% for item in invoice_items %}
<q-item clickable v-ripple>
<q-item-section><b>{{item.description}}</b></q-item-section>
<q-item-section side>
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
}}
</q-item-section>
</q-item>
{% endfor %} {% if not invoice_items %} No Invoice Items {% endif %}
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<div class="clear"></div>
<div class="row">
<div class="col-12 col-md">
<q-card>
<q-card-section>
<p>
<b>Payments</b>
</p>
<q-list bordered separator>
{% if invoice_payments %}
<q-item clickable v-ripple>
<q-item-section><b>Date</b></q-item-section>
<q-item-section side><b>Amount</b></q-item-section>
</q-item>
{% endif %} {% for item in invoice_payments %}
<q-item clickable v-ripple>
<q-item-section
><b
>{{ datetime.utcfromtimestamp(item.time).strftime('%Y-%m-%d
%H:%M') }}</b
></q-item-section
>
<q-item-section side>
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
}}
</q-item-section>
</q-item>
{% endfor %} {% if not invoice_payments %} No Invoice Payments {%
endif %}
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<div class="clear"></div>
<div class="row q-gutter-y-md q-gutter-md" id="printQrCode">
<div class="col-12 col-md">
<div class="text-center">
<p><b>Scan to View & Pay Online!</b></p>
<qrcode
value="{{ request.url }}"
:options="{width: 200}"
class="rounded-borders"
></qrcode>
</div>
</div>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createPayment" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.payment_amount"
:rules="[val => val >= 0.01 || 'Minimum amount is 0.01']"
min="0.01"
label="Payment Amount"
placeholder="4.20"
>
<template v-slot:append>
<span style="font-size: 12px"> {{ invoice.currency }} </span>
</template>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.payment_amount == null"
type="submit"
>Create Payment</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog
v-model="qrCodeDialog.show"
position="top"
@hide="closeQrCodeDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card text-center">
<a
class="text-secondary"
:href="'lightning:' + qrCodeDialog.data.payment_request"
>
<q-responsive :ratio="1" class="q-mx-xs">
<qrcode
:value="'lightning:' + qrCodeDialog.data.payment_request"
:options="{width: 400}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<br />
<q-btn
outline
color="grey"
@click="copyText('lightning:' + qrCodeDialog.data.payment_request, 'Invoice copied to clipboard!')"
>Copy Invoice</q-btn
>
</q-card>
</q-dialog>
<q-dialog v-model="urlDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
value="{{ request.url }}"
:options="{width: 400}"
class="rounded-borders"
></qrcode>
</q-responsive>
<div class="text-center q-mb-xl">
<p style="word-break: break-all">{{ request.url }}</p>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText('{{ request.url }}', 'Invoice Pay URL copied to clipboard!')"
>Copy URL</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script>
var mapInvoice = function (obj) {
obj.time = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
return obj
}
var mapInvoiceItems = function (obj) {
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
return obj
}
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
invoice_id: '{{ invoice.id }}',
wallet: '{{ invoice.wallet }}',
currency: '{{ invoice.currency }}',
status: '{{ invoice.status }}',
qrCodeDialog: {
data: {
payment_request: null,
},
show: false,
},
formDialog: {
data: {
payment_amount: parseFloat({{invoice_total - payments_total}} / 100).toFixed(2)
},
show: false,
},
urlDialog: {
show: false,
},
}
},
methods: {
printInvoice: function() {
window.print()
},
closeFormDialog: function() {
this.formDialog.show = false
},
closeQrCodeDialog: function() {
this.qrCodeDialog.show = false
},
createPayment: function () {
var self = this
var qrCodeDialog = this.qrCodeDialog
var formDialog = this.formDialog
var famount = parseInt(formDialog.data.payment_amount * 100)
axios
.post('/invoices/api/v1/invoice/' + this.invoice_id + '/payments', null, {
params: {
famount: famount,
}
})
.then(function (response) {
formDialog.show = false
formDialog.data = {}
qrCodeDialog.data = response.data
qrCodeDialog.show = true
console.log(qrCodeDialog.data)
qrCodeDialog.dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
qrCodeDialog.paymentChecker = setInterval(function () {
axios
.get(
'/invoices/api/v1/invoice/' +
self.invoice_id +
'/payments/' +
response.data.payment_hash
)
.then(function (res) {
if (res.data.paid) {
clearInterval(qrCodeDialog.paymentChecker)
qrCodeDialog.dismissMsg()
qrCodeDialog.show = false
setTimeout(function () {
window.location.reload()
}, 500)
}
})
}, 3000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
},
computed: {
statusBadgeColor: function() {
switch(this.status) {
case 'draft':
return 'gray'
break
case 'open':
return 'blue'
break
case 'paid':
return 'green'
break
case 'canceled':
return 'red'
break
}
},
},
created: function () {
}
})
</script>
{% endblock %}

View file

@ -1,57 +0,0 @@
from datetime import datetime
from http import HTTPStatus
from fastapi import Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import invoices_ext, invoices_renderer
from .crud import (
get_invoice,
get_invoice_items,
get_invoice_payments,
get_invoice_total,
get_payments_total,
)
templates = Jinja2Templates(directory="templates")
@invoices_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return invoices_renderer().TemplateResponse(
"invoices/index.html", {"request": request, "user": user.dict()}
)
@invoices_ext.get("/pay/{invoice_id}", response_class=HTMLResponse)
async def pay(request: Request, invoice_id: str):
invoice = await get_invoice(invoice_id)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
invoice_items = await get_invoice_items(invoice_id)
invoice_total = await get_invoice_total(invoice_items)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
return invoices_renderer().TemplateResponse(
"invoices/pay.html",
{
"request": request,
"invoice_id": invoice_id,
"invoice": invoice.dict(),
"invoice_items": invoice_items,
"invoice_total": invoice_total,
"invoice_payments": invoice_payments,
"payments_total": payments_total,
"datetime": datetime,
},
)

View file

@ -1,133 +0,0 @@
from http import HTTPStatus
from fastapi import Depends, HTTPException, Query
from loguru import logger
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
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from . import invoices_ext
from .crud import (
create_invoice_internal,
create_invoice_items,
get_invoice,
get_invoice_items,
get_invoice_payments,
get_invoice_total,
get_invoices,
get_payments_total,
update_invoice_internal,
update_invoice_items,
)
from .models import CreateInvoiceData, UpdateInvoiceData
@invoices_ext.get("/api/v1/invoices", status_code=HTTPStatus.OK)
async def api_invoices(
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else []
return [invoice.dict() for invoice in await get_invoices(wallet_ids)]
@invoices_ext.get("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
async def api_invoice(invoice_id: str):
invoice = await get_invoice(invoice_id)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
invoice_items = await get_invoice_items(invoice_id)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
invoice_dict = invoice.dict()
invoice_dict["items"] = invoice_items
invoice_dict["payments"] = payments_total
return invoice_dict
@invoices_ext.post("/api/v1/invoice", status_code=HTTPStatus.CREATED)
async def api_invoice_create(
data: CreateInvoiceData, wallet: WalletTypeInfo = Depends(get_key_type)
):
invoice = await create_invoice_internal(wallet_id=wallet.wallet.id, data=data)
items = await create_invoice_items(invoice_id=invoice.id, data=data.items)
invoice_dict = invoice.dict()
invoice_dict["items"] = items
return invoice_dict
@invoices_ext.post("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
async def api_invoice_update(
data: UpdateInvoiceData,
invoice_id: str,
wallet: WalletTypeInfo = Depends(get_key_type),
):
invoice = await update_invoice_internal(wallet_id=wallet.wallet.id, data=data)
items = await update_invoice_items(invoice_id=invoice.id, data=data.items)
invoice_dict = invoice.dict()
invoice_dict["items"] = items
return invoice_dict
@invoices_ext.post(
"/api/v1/invoice/{invoice_id}/payments", status_code=HTTPStatus.CREATED
)
async def api_invoices_create_payment(invoice_id: str, famount: int = Query(..., ge=1)):
invoice = await get_invoice(invoice_id)
invoice_items = await get_invoice_items(invoice_id)
invoice_total = await get_invoice_total(invoice_items)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
if payments_total + famount > invoice_total:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Amount exceeds invoice due."
)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
price_in_sats = await fiat_amount_as_satoshis(famount / 100, invoice.currency)
try:
payment_hash, payment_request = await create_invoice(
wallet_id=invoice.wallet,
amount=price_in_sats,
memo=f"Payment for invoice {invoice_id}",
extra={"tag": "invoices", "invoice_id": invoice_id, "famount": famount},
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return {"payment_hash": payment_hash, "payment_request": payment_request}
@invoices_ext.get(
"/api/v1/invoice/{invoice_id}/payments/{payment_hash}", status_code=HTTPStatus.OK
)
async def api_invoices_check_payment(invoice_id: str, payment_hash: str):
invoice = await get_invoice(invoice_id)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
try:
status = await api_payment(payment_hash)
except Exception as exc:
logger.error(exc)
return {"paid": False}
return status

View file

@ -1,68 +0,0 @@
<h1>Lightning Address</h1>
<h2>Rent Lightning Addresses on your domain</h2>
LNAddress extension allows for someone to rent users lightning addresses on their domain.
The extension is muted by default on the .env file and needs the admin of the LNbits instance to meet a few requirements on the server.
## Requirements
- Free Cloudflare account
- Cloudflare as a DNS server provider
- Cloudflare TOKEN and Cloudflare zone-ID where the domain is parked
The server must provide SSL/TLS certificates to domain owners. If using caddy, this can be easily achieved with the Caddyfife snippet:
```
:443 {
reverse_proxy localhost:5000
tls <your email>@example.com {
on_demand
}
}
```
fill in with your email.
Certbot is also a possibity.
## Usage
1. Before adding a domain, you need to add the domain to Cloudflare and get an API key and Secret key\
![add domain to Cloudflare](https://i.imgur.com/KTJK7uT.png)\
You can use the _Edit zone DNS_ template Cloudflare provides.\
![DNS template](https://i.imgur.com/ciRXuGd.png)\
Edit the template as you like, if only using one domain you can narrow the scope of the template\
![edit template](https://i.imgur.com/NCUF72C.png)
2. Back on LNbits, click "ADD DOMAIN"\
![add domain](https://i.imgur.com/9Ed3NX4.png)
3. Fill the form with the domain information\
![fill form](https://i.imgur.com/JMcXXbS.png)
- select your wallet - add your domain
- cloudflare keys
- an optional webhook to get notified
- the amount, in sats, you'll rent the addresses, per day
4. Your domains will show up on the _Domains_ section\
![domains card](https://i.imgur.com/Fol1Arf.png)\
On the left side, is the link to share with users so they can rent an address on your domain. When someone creates an address, after pay, they will be shown on the _Addresses_ section\
![address card](https://i.imgur.com/judrIeo.png)
5. Addresses get automatically purged if expired or unpaid, after 24 hours. After expiration date, users will be granted a 24 hours period to renew their address!
6. On the user/buyer side, the webpage will present the _Create_ or _Renew_ address tabs. On the Create tab:\
![create address](https://i.imgur.com/lSYWGeT.png)
- optional email
- the alias or username they want on your domain
- the LNbits URL, if not the same instance (for example the user has an LNbits wallet on https://s.lnbits.com and is renting an address from https://lnbits.com)
- the _Admin key_ for the wallet
- how many days to rent a username for - bellow shows the per day cost and total cost the user will have to pay
7. On the Renew tab:\
![renew address](https://i.imgur.com/rzU46ps.png)
- enter the Alias/username
- enter the wallet key
- press the _GET INFO_ button to retrieve your address data
- an expiration date will appear and the option to extend the duration of your address

View file

@ -1,35 +0,0 @@
import asyncio
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_lnaddress")
lnaddress_ext: APIRouter = APIRouter(prefix="/lnaddress", tags=["lnaddress"])
lnaddress_static_files = [
{
"path": "/lnaddress/static",
"app": StaticFiles(directory="lnbits/extensions/lnaddress/static"),
"name": "lnaddress_static",
}
]
def lnaddress_renderer():
return template_renderer(["lnbits/extensions/lnaddress/templates"])
from .lnurl import * # noqa: F401,F403
from .tasks import wait_for_paid_invoices
from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403
def lnaddress_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View file

@ -1,54 +0,0 @@
import httpx
from .models import Domains
async def cloudflare_create_record(domain: Domains, ip: str):
url = (
"https://api.cloudflare.com/client/v4/zones/"
+ domain.cf_zone_id
+ "/dns_records"
)
header = {
"Authorization": "Bearer " + domain.cf_token,
"Content-Type": "application/json",
}
cf_response = {}
async with httpx.AsyncClient() as client:
try:
r = await client.post(
url,
headers=header,
json={
"type": "CNAME",
"name": domain.domain,
"content": ip,
"ttl": 0,
"proxied": False,
},
timeout=40,
)
cf_response = r.json()
except AssertionError:
cf_response = {"error": "Error occured"}
return cf_response
async def cloudflare_deleterecord(domain: Domains, domain_id: str):
url = (
"https://api.cloudflare.com/client/v4/zones/"
+ domain.cf_zone_id
+ "/dns_records"
)
header = {
"Authorization": "Bearer " + domain.cf_token,
"Content-Type": "application/json",
}
async with httpx.AsyncClient() as client:
try:
r = await client.delete(url + "/" + domain_id, headers=header, timeout=40)
cf_response = r.text
except AssertionError:
cf_response = "Error occured"
return cf_response

View file

@ -1,6 +0,0 @@
{
"name": "Lightning Address",
"short_description": "Sell LN addresses for your domain",
"tile": "/lnaddress/static/image/lnaddress.png",
"contributors": ["talvasconcelos"]
}

View file

@ -1,198 +0,0 @@
from datetime import datetime, timedelta
from typing import List, Optional, Union
from loguru import logger
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Addresses, CreateAddress, CreateDomain, Domains
async def create_domain(data: CreateDomain) -> Domains:
domain_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO lnaddress.domain (id, wallet, domain, webhook, cf_token, cf_zone_id, cost)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
domain_id,
data.wallet,
data.domain,
data.webhook,
data.cf_token,
data.cf_zone_id,
data.cost,
),
)
new_domain = await get_domain(domain_id)
assert new_domain, "Newly created domain couldn't be retrieved"
return new_domain
async def update_domain(domain_id: str, **kwargs) -> Domains:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE lnaddress.domain SET {q} WHERE id = ?", (*kwargs.values(), domain_id)
)
row = await db.fetchone("SELECT * FROM lnaddress.domain WHERE id = ?", (domain_id,))
assert row, "Newly updated domain couldn't be retrieved"
return Domains(**row)
async def delete_domain(domain_id: str) -> None:
await db.execute("DELETE FROM lnaddress.domain WHERE id = ?", (domain_id,))
async def get_domain(domain_id: str) -> Optional[Domains]:
row = await db.fetchone("SELECT * FROM lnaddress.domain WHERE id = ?", (domain_id,))
return Domains(**row) if row else None
async def get_domains(wallet_ids: Union[str, List[str]]) -> List[Domains]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM lnaddress.domain WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Domains(**row) for row in rows]
## ADRESSES
async def create_address(
payment_hash: str, wallet: str, data: CreateAddress
) -> Addresses:
await db.execute(
"""
INSERT INTO lnaddress.address (id, wallet, domain, email, username, wallet_key, wallet_endpoint, sats, duration, paid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
payment_hash,
wallet,
data.domain,
data.email,
data.username,
data.wallet_key,
data.wallet_endpoint,
data.sats,
data.duration,
False,
),
)
new_address = await get_address(payment_hash)
assert new_address, "Newly created address couldn't be retrieved"
return new_address
async def get_address(address_id: str) -> Optional[Addresses]:
row = await db.fetchone(
"SELECT a.* FROM lnaddress.address AS a INNER JOIN lnaddress.domain AS d ON a.id = ? AND a.domain = d.id",
(address_id,),
)
return Addresses(**row) if row else None
async def get_address_by_username(username: str, domain: str) -> Optional[Addresses]:
row = await db.fetchone(
"SELECT a.* FROM lnaddress.address AS a INNER JOIN lnaddress.domain AS d ON a.username = ? AND d.domain = ?",
(username, domain),
)
return Addresses(**row) if row else None
async def delete_address(address_id: str) -> None:
await db.execute("DELETE FROM lnaddress.address WHERE id = ?", (address_id,))
async def get_addresses(wallet_ids: Union[str, List[str]]) -> List[Addresses]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM lnaddress.address WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Addresses(**row) for row in rows]
async def set_address_paid(payment_hash: str) -> Addresses:
address = await get_address(payment_hash)
assert address
if address.paid is False:
await db.execute(
"""
UPDATE lnaddress.address
SET paid = true
WHERE id = ?
""",
(payment_hash,),
)
new_address = await get_address(payment_hash)
assert new_address, "Newly paid address couldn't be retrieved"
return new_address
async def set_address_renewed(address_id: str, duration: int):
address = await get_address(address_id)
assert address
extend_duration = int(address.duration) + duration
await db.execute(
"""
UPDATE lnaddress.address
SET duration = ?
WHERE id = ?
""",
(extend_duration, address_id),
)
updated_address = await get_address(address_id)
assert updated_address, "Renewed address couldn't be retrieved"
return updated_address
async def check_address_available(username: str, domain: str):
(row,) = await db.fetchone(
"SELECT COUNT(username) FROM lnaddress.address WHERE username = ? AND domain = ?",
(username, domain),
)
return row
async def purge_addresses(domain_id: str):
rows = await db.fetchall(
"SELECT * FROM lnaddress.address WHERE domain = ?", (domain_id,)
)
now = datetime.now().timestamp()
for row in rows:
r = Addresses(**row).dict()
start = datetime.fromtimestamp(r["time"])
paid = r["paid"]
pay_expire = now > start.timestamp() + 86400 # if payment wasn't made in 1 day
expired = (
now > (start + timedelta(days=r["duration"] + 1)).timestamp()
) # give user 1 day to topup is address
if not paid and pay_expire:
logger.debug("DELETE UNP_PAY_EXP", r["username"])
await delete_address(r["id"])
if paid and expired:
logger.debug("DELETE PAID_EXP", r["username"])
await delete_address(r["id"])

Some files were not shown because too many files have changed in this diff Show more