mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-23 14:40:47 +01:00
Merge branch 'main' into default_ext_install
This commit is contained in:
commit
bb26f2c7f0
321 changed files with 230 additions and 44770 deletions
5
.github/workflows/formatting.yml
vendored
5
.github/workflows/formatting.yml
vendored
|
@ -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
2
.gitignore
vendored
|
@ -45,4 +45,4 @@ fly.toml
|
|||
|
||||
# Ignore extensions (post installable extension PR)
|
||||
extensions/
|
||||
upgrades/
|
||||
upgrades/
|
||||
|
|
10
.prettierignore
Normal file
10
.prettierignore
Normal file
|
@ -0,0 +1,10 @@
|
|||
**/.git
|
||||
**/.svn
|
||||
**/.hg
|
||||
**/node_modules
|
||||
|
||||
*.yml
|
||||
|
||||
**/lnbits/static/vendor
|
||||
**/lnbits/static/bundle.*
|
||||
**/lnbits/static/css/*
|
8
Makefile
8
Makefile
|
@ -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 .
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
filled
|
||||
v-model="formData.lightning_invoice_expiry"
|
||||
label="Invoice expiry (seconds)"
|
||||
mask="#######"
|
||||
mask="#######"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
|
|
|
@ -58,6 +58,5 @@
|
|||
</div>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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.
|
|
@ -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
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "Bleskomat",
|
||||
"short_description": "Connect a Bleskomat ATM to an lnbits",
|
||||
"tile": "/bleskomat/static/image/bleskomat.png",
|
||||
"contributors": ["chill117"]
|
||||
}
|
|
@ -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
|
|
@ -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))
|
|
@ -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"
|
||||
}
|
|
@ -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
|
|
@ -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"}
|
|
@ -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)
|
||||
);
|
||||
"""
|
||||
)
|
|
@ -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 |
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -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},
|
||||
)
|
|
@ -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
|
|
@ -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.
|
||||
---
|
||||

|
||||
---
|
||||
2. after you confirm your inputs, following dialog with the QR code for the onchain transaction, onchain- address and amount, will pop up.
|
||||
---
|
||||

|
||||
---
|
||||
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.
|
||||
---
|
||||

|
||||
---
|
||||
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.
|
||||
---
|
||||

|
||||
----
|
||||
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.
|
|
@ -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))
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "Boltz",
|
||||
"short_description": "Perform onchain/offchain swaps",
|
||||
"tile": "/boltz/static/image/boltz.png",
|
||||
"contributors": ["dni"]
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
|
@ -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 |
|
@ -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)}"
|
||||
)
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -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))
|
|
@ -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},
|
||||
)
|
|
@ -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,
|
||||
}
|
|
@ -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
|
|
@ -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))
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"name": "Cashu",
|
||||
"short_description": "Ecash mint and wallet",
|
||||
"tile": "/cashu/static/image/cashu.png",
|
||||
"contributors": ["calle", "vlad", "arcbtc"],
|
||||
"hidden": false
|
||||
}
|
|
@ -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,))
|
|
@ -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
|
||||
);
|
||||
"""
|
||||
)
|
|
@ -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 |
|
@ -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
|
||||
})({})
|
|
@ -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
|
@ -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}`)
|
||||
}
|
|
@ -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
|
|
@ -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": <invoice_key>}</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>[<cashu_object>, ...]</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:
|
||||
<invoice_key>"
|
||||
</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": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"name": <string>, "currency": <string*ie USD*>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"currency": <string>, "id": <string>, "name":
|
||||
<string>, "wallet": <string>}</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":
|
||||
<string>, "currency": <string>}' -H "Content-type:
|
||||
application/json" -H "X-Api-Key: <admin_key>"
|
||||
</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/<cashu_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</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/<cashu_id> -H "X-Api-Key:
|
||||
<admin_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item> -->
|
||||
</q-expansion-item>
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -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
|
@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "Deezy",
|
||||
"short_description": "LN to onchain, onchain to LN swaps",
|
||||
"tile": "/deezy/static/deezy.png",
|
||||
"contributors": ["Uthpala"]
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
)
|
|
@ -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
|
||||
);
|
||||
"""
|
||||
)
|
|
@ -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 |
|
@ -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>
|
|
@ -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 %}
|
|
@ -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()}
|
||||
)
|
|
@ -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
|
|
@ -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"\
|
||||

|
||||
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\
|
||||

|
|
@ -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
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "Invoices",
|
||||
"short_description": "Create invoices for your clients.",
|
||||
"tile": "/invoices/static/image/invoices.png",
|
||||
"contributors": ["leesalminen"]
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
);
|
||||
"""
|
||||
)
|
|
@ -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
|
|
@ -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 |
|
@ -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
|
|
@ -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": <invoice_key>}</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/invoices -H
|
||||
"X-Api-Key: <invoice_key>"
|
||||
</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": <invoice_key>}</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:
|
||||
<invoice_key>"
|
||||
</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": <invoice_key>}</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: <invoice_key>"
|
||||
</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": <invoice_key>}</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:
|
||||
<invoice_key>"
|
||||
</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:
|
||||
<invoice_key>"
|
||||
</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: <invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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,
|
||||
},
|
||||
)
|
|
@ -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
|
|
@ -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\
|
||||
\
|
||||
You can use the _Edit zone DNS_ template Cloudflare provides.\
|
||||
\
|
||||
Edit the template as you like, if only using one domain you can narrow the scope of the template\
|
||||

|
||||
|
||||
2. Back on LNbits, click "ADD DOMAIN"\
|
||||

|
||||
|
||||
3. Fill the form with the domain information\
|
||||

|
||||
|
||||
- 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\
|
||||
\
|
||||
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\
|
||||

|
||||
|
||||
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:\
|
||||

|
||||
- 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:\
|
||||

|
||||
- 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
|
|
@ -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))
|
|
@ -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
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "Lightning Address",
|
||||
"short_description": "Sell LN addresses for your domain",
|
||||
"tile": "/lnaddress/static/image/lnaddress.png",
|
||||
"contributors": ["talvasconcelos"]
|
||||
}
|
|
@ -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
Loading…
Add table
Reference in a new issue