mirror of
https://github.com/lnbits/lnbits.git
synced 2025-10-10 12:32:34 +02:00
Merge branch 'FastAPI' into fastapisatsdice
This commit is contained in:
27
lnbits/extensions/satspay/README.md
Normal file
27
lnbits/extensions/satspay/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# SatsPay Server
|
||||||
|
|
||||||
|
## Create onchain and LN charges. Includes webhooks!
|
||||||
|
|
||||||
|
Easilly create invoices that support Lightning Network and on-chain BTC payment.
|
||||||
|
|
||||||
|
1. Create a "NEW CHARGE"\
|
||||||
|

|
||||||
|
2. Fill out the invoice fields
|
||||||
|
- set a descprition for the payment
|
||||||
|
- the amount in sats
|
||||||
|
- the time, in minutes, the invoice is valid for, after this period the invoice can't be payed
|
||||||
|
- set a webhook that will get the transaction details after a successful payment
|
||||||
|
- set to where the user should redirect after payment
|
||||||
|
- set the text for the button that will show after payment (not setting this, will display "NONE" in the button)
|
||||||
|
- select if you want onchain payment, LN payment or both
|
||||||
|
- depending on what you select you'll have to choose the respective wallets where to receive your payment\
|
||||||
|

|
||||||
|
3. The charge will appear on the _Charges_ section\
|
||||||
|

|
||||||
|
4. Your costumer/payee will get the payment page
|
||||||
|
- they can choose to pay on LN\
|
||||||
|

|
||||||
|
- or pay on chain\
|
||||||
|

|
||||||
|
5. You can check the state of your charges in LNBits\
|
||||||
|

|
25
lnbits/extensions/satspay/__init__.py
Normal file
25
lnbits/extensions/satspay/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
|
||||||
|
db = Database("ext_satspay")
|
||||||
|
|
||||||
|
|
||||||
|
satspay_ext: APIRouter = APIRouter(
|
||||||
|
prefix="/satspay",
|
||||||
|
tags=["satspay"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def satspay_renderer():
|
||||||
|
return template_renderer(
|
||||||
|
[
|
||||||
|
"lnbits/extensions/satspay/templates",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from .views_api import * # noqa
|
||||||
|
from .views import * # noqa
|
8
lnbits/extensions/satspay/config.json
Normal file
8
lnbits/extensions/satspay/config.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "SatsPay Server",
|
||||||
|
"short_description": "Create onchain and LN charges",
|
||||||
|
"icon": "payment",
|
||||||
|
"contributors": [
|
||||||
|
"arcbtc"
|
||||||
|
]
|
||||||
|
}
|
122
lnbits/extensions/satspay/crud.py
Normal file
122
lnbits/extensions/satspay/crud.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
# from lnbits.db import open_ext_db
|
||||||
|
from . import db
|
||||||
|
from .models import Charges, CreateCharge
|
||||||
|
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from lnbits.core.services import create_invoice, check_invoice_status
|
||||||
|
from ..watchonly.crud import get_watch_wallet, get_fresh_address, get_mempool
|
||||||
|
|
||||||
|
|
||||||
|
###############CHARGES##########################
|
||||||
|
|
||||||
|
|
||||||
|
async def create_charge(
|
||||||
|
user: str,
|
||||||
|
data: CreateCharge
|
||||||
|
) -> Charges:
|
||||||
|
charge_id = urlsafe_short_hash()
|
||||||
|
if data.onchainwallet:
|
||||||
|
wallet = await get_watch_wallet(data.onchainwallet)
|
||||||
|
onchain = await get_fresh_address(data.onchainwallet)
|
||||||
|
onchainaddress = onchain.address
|
||||||
|
else:
|
||||||
|
onchainaddress = None
|
||||||
|
if data.lnbitswallet:
|
||||||
|
payment_hash, payment_request = await create_invoice(
|
||||||
|
wallet_id=data.lnbitswallet, amount=data.amount, memo=charge_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
payment_hash = None
|
||||||
|
payment_request = None
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO satspay.charges (
|
||||||
|
id,
|
||||||
|
"user",
|
||||||
|
description,
|
||||||
|
onchainwallet,
|
||||||
|
onchainaddress,
|
||||||
|
lnbitswallet,
|
||||||
|
payment_request,
|
||||||
|
payment_hash,
|
||||||
|
webhook,
|
||||||
|
completelink,
|
||||||
|
completelinktext,
|
||||||
|
time,
|
||||||
|
amount,
|
||||||
|
balance
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
charge_id,
|
||||||
|
user,
|
||||||
|
data.description,
|
||||||
|
data.onchainwallet,
|
||||||
|
onchainaddress,
|
||||||
|
data.lnbitswallet,
|
||||||
|
payment_request,
|
||||||
|
payment_hash,
|
||||||
|
data.webhook,
|
||||||
|
data.completelink,
|
||||||
|
data.completelinktext,
|
||||||
|
data.time,
|
||||||
|
data.amount,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return await get_charge(charge_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_charge(charge_id: str, **kwargs) -> Optional[Charges]:
|
||||||
|
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE satspay.charges SET {q} WHERE id = ?", (*kwargs.values(), charge_id)
|
||||||
|
)
|
||||||
|
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
|
||||||
|
return Charges.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_charge(charge_id: str) -> Charges:
|
||||||
|
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
|
||||||
|
return Charges.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_charges(user: str) -> List[Charges]:
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"""SELECT * FROM satspay.charges WHERE "user" = ?""", (user,)
|
||||||
|
)
|
||||||
|
return [Charges.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_charge(charge_id: str) -> None:
|
||||||
|
await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,))
|
||||||
|
|
||||||
|
|
||||||
|
async def check_address_balance(charge_id: str) -> List[Charges]:
|
||||||
|
charge = await get_charge(charge_id)
|
||||||
|
if not charge.paid:
|
||||||
|
if charge.onchainaddress:
|
||||||
|
mempool = await get_mempool(charge.user)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.get(
|
||||||
|
mempool.endpoint + "/api/address/" + charge.onchainaddress
|
||||||
|
)
|
||||||
|
respAmount = r.json()["chain_stats"]["funded_txo_sum"]
|
||||||
|
if respAmount >= charge.balance:
|
||||||
|
await update_charge(charge_id=charge_id, balance=respAmount)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if charge.lnbitswallet:
|
||||||
|
invoice_status = await check_invoice_status(
|
||||||
|
charge.lnbitswallet, charge.payment_hash
|
||||||
|
)
|
||||||
|
if invoice_status.paid:
|
||||||
|
return await update_charge(charge_id=charge_id, balance=charge.amount)
|
||||||
|
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
|
||||||
|
return Charges.from_row(row) if row else None
|
28
lnbits/extensions/satspay/migrations.py
Normal file
28
lnbits/extensions/satspay/migrations.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial wallet table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE satspay.charges (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"user" TEXT,
|
||||||
|
description TEXT,
|
||||||
|
onchainwallet TEXT,
|
||||||
|
onchainaddress TEXT,
|
||||||
|
lnbitswallet TEXT,
|
||||||
|
payment_request TEXT,
|
||||||
|
payment_hash TEXT,
|
||||||
|
webhook TEXT,
|
||||||
|
completelink TEXT,
|
||||||
|
completelinktext TEXT,
|
||||||
|
time INTEGER,
|
||||||
|
amount INTEGER,
|
||||||
|
balance INTEGER DEFAULT 0,
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT """
|
||||||
|
+ db.timestamp_now
|
||||||
|
+ """
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
50
lnbits/extensions/satspay/models.py
Normal file
50
lnbits/extensions/satspay/models.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from sqlite3 import Row
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import time
|
||||||
|
|
||||||
|
class CreateCharge(BaseModel):
|
||||||
|
onchainwallet: str = Query(None)
|
||||||
|
lnbitswallet: str = Query(None)
|
||||||
|
description: str = Query(...)
|
||||||
|
webhook: str = Query(None)
|
||||||
|
completelink: str = Query(None)
|
||||||
|
completelinktext: str = Query(None)
|
||||||
|
time: int = Query(..., ge=1)
|
||||||
|
amount: int = Query(..., ge=1)
|
||||||
|
|
||||||
|
class Charges(BaseModel):
|
||||||
|
id: str
|
||||||
|
user: str
|
||||||
|
description: Optional[str]
|
||||||
|
onchainwallet: Optional[str]
|
||||||
|
onchainaddress: Optional[str]
|
||||||
|
lnbitswallet: Optional[str]
|
||||||
|
payment_request: str
|
||||||
|
payment_hash: str
|
||||||
|
webhook: Optional[str]
|
||||||
|
completelink: Optional[str]
|
||||||
|
completelinktext: Optional[str] = "Back to Merchant"
|
||||||
|
time: int
|
||||||
|
amount: int
|
||||||
|
balance: int
|
||||||
|
timestamp: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "Charges":
|
||||||
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time_elapsed(self):
|
||||||
|
if (self.timestamp + (self.time * 60)) >= time.time():
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def paid(self):
|
||||||
|
if self.balance >= self.amount:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
171
lnbits/extensions/satspay/templates/satspay/_api_docs.html
Normal file
171
lnbits/extensions/satspay/templates/satspay/_api_docs.html
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<p>
|
||||||
|
SatsPayServer, create Onchain/LN charges.<br />WARNING: If using with the
|
||||||
|
WatchOnly extension, we highly reccomend using a fresh extended public Key
|
||||||
|
specifically for SatsPayServer!<br />
|
||||||
|
<small>
|
||||||
|
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
<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="Create charge">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">POST</span> /satspay/api/v1/charge</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">
|
||||||
|
Body (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<charge_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.url_root }}api/v1/charge -d
|
||||||
|
'{"onchainwallet": <string, watchonly_wallet_id>,
|
||||||
|
"description": <string>, "webhook":<string>, "time":
|
||||||
|
<integer>, "amount": <integer>, "lnbitswallet":
|
||||||
|
<string, lnbits_wallet_id>}' -H "Content-type:
|
||||||
|
application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Update charge">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">PUT</span>
|
||||||
|
/satspay/api/v1/charge/<charge_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">
|
||||||
|
Body (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<charge_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.url_root }}api/v1/charge/<charge_id>
|
||||||
|
-d '{"onchainwallet": <string, watchonly_wallet_id>,
|
||||||
|
"description": <string>, "webhook":<string>, "time":
|
||||||
|
<integer>, "amount": <integer>, "lnbitswallet":
|
||||||
|
<string, lnbits_wallet_id>}' -H "Content-type:
|
||||||
|
application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Get charge">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span>
|
||||||
|
/satspay/api/v1/charge/<charge_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>[<charge_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}api/v1/charge/<charge_id>
|
||||||
|
-H "X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Get charges">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span> /satspay/api/v1/charges</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>[<charge_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}api/v1/charges -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Delete a pay link"
|
||||||
|
class="q-pb-md"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-pink">DELETE</span>
|
||||||
|
/satspay/api/v1/charge/<charge_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.url_root
|
||||||
|
}}api/v1/charge/<charge_id> -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Get balances">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span>
|
||||||
|
/satspay/api/v1/charges/balance/<charge_id></code
|
||||||
|
>
|
||||||
|
<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>[<charge_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root
|
||||||
|
}}api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-card>
|
319
lnbits/extensions/satspay/templates/satspay/display.html
Normal file
319
lnbits/extensions/satspay/templates/satspay/display.html
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
{% extends "public.html" %} {% block page %}
|
||||||
|
<div class="q-pa-sm theCard">
|
||||||
|
<q-card class="my-card">
|
||||||
|
<div class="column">
|
||||||
|
<center>
|
||||||
|
<div class="col theHeading">{{ charge.description }}</div>
|
||||||
|
</center>
|
||||||
|
<div class="col">
|
||||||
|
<div
|
||||||
|
class="col"
|
||||||
|
color="white"
|
||||||
|
style="background-color: grey; height: 30px; padding: 5px"
|
||||||
|
v-if="timetoComplete < 1"
|
||||||
|
>
|
||||||
|
<center>Time elapsed</center>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="col"
|
||||||
|
color="white"
|
||||||
|
style="background-color: grey; height: 30px; padding: 5px"
|
||||||
|
v-else-if="charge_paid == 'True'"
|
||||||
|
>
|
||||||
|
<center>Charge paid</center>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<q-linear-progress size="30px" :value="newProgress" color="grey">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item style="padding: 3px">
|
||||||
|
<q-spinner color="white" size="0.8em"></q-spinner
|
||||||
|
><span style="font-size: 15px; color: white"
|
||||||
|
><span class="q-pr-xl q-pl-md"> Awaiting payment...</span>
|
||||||
|
<span class="q-pl-xl" style="color: white">
|
||||||
|
{% raw %} {{ newTimeLeft }} {% endraw %}</span
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
</q-item>
|
||||||
|
</q-item-section>
|
||||||
|
</q-linear-progress>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col" style="margin: 2px 15px; max-height: 100px">
|
||||||
|
<center>
|
||||||
|
<q-btn flat dense outline @click="copyText('{{ charge.id }}')"
|
||||||
|
>Charge ID: {{ charge.id }}</q-btn
|
||||||
|
>
|
||||||
|
</center>
|
||||||
|
<span
|
||||||
|
><small
|
||||||
|
>{% raw %} Total to pay: {{ charge_amount }}sats<br />
|
||||||
|
Amount paid: {{ charge_balance }}</small
|
||||||
|
><br />
|
||||||
|
Amount due: {{ charge_amount - charge_balance }}sats {% endraw %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<div class="col">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
disable
|
||||||
|
v-if="'{{ charge.lnbitswallet }}' == 'None' || charge_time_elapsed == 'True'"
|
||||||
|
style="color: primary; width: 100%"
|
||||||
|
label="lightning⚡"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
bitcoin onchain payment method not available
|
||||||
|
</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
v-else
|
||||||
|
@click="payLN"
|
||||||
|
style="color: primary; width: 100%"
|
||||||
|
label="lightning⚡"
|
||||||
|
>
|
||||||
|
<q-tooltip> pay with lightning </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
disable
|
||||||
|
v-if="'{{ charge.onchainwallet }}' == 'None' || charge_time_elapsed == 'True'"
|
||||||
|
style="color: primary; width: 100%"
|
||||||
|
label="onchain⛓️"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
bitcoin lightning payment method not available
|
||||||
|
</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
v-else
|
||||||
|
@click="payON"
|
||||||
|
style="color: primary; width: 100%"
|
||||||
|
label="onchain⛓️"
|
||||||
|
>
|
||||||
|
<q-tooltip> pay onchain </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-card class="q-pa-lg" v-if="lnbtc">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<div class="text-center q-pt-md">
|
||||||
|
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
|
||||||
|
<q-icon
|
||||||
|
name="block"
|
||||||
|
style="color: #ccc; font-size: 21.4em"
|
||||||
|
></q-icon>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="charge_paid == 'True'">
|
||||||
|
<q-icon
|
||||||
|
name="check"
|
||||||
|
style="color: green; font-size: 21.4em"
|
||||||
|
></q-icon>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
v-if="'{{ charge.webhook }}' != 'None'"
|
||||||
|
type="a"
|
||||||
|
href="{{ charge.completelink }}"
|
||||||
|
label="{{ charge.completelinktext }}"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<center>
|
||||||
|
<span class="text-subtitle2"
|
||||||
|
>Pay this <br />
|
||||||
|
lightning-network invoice</span
|
||||||
|
>
|
||||||
|
</center>
|
||||||
|
<a href="lightning://{{ charge.payment_request }}">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-md">
|
||||||
|
<qrcode
|
||||||
|
:value="'{{ charge.payment_request }}'"
|
||||||
|
:options="{width: 800}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText('{{ charge.payment_request }}')"
|
||||||
|
>Copy invoice</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<q-card class="q-pa-lg" v-if="onbtc">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<div class="text-center q-pt-md">
|
||||||
|
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
|
||||||
|
<q-icon
|
||||||
|
name="block"
|
||||||
|
style="color: #ccc; font-size: 21.4em"
|
||||||
|
></q-icon>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="charge_paid == 'True'">
|
||||||
|
<q-icon
|
||||||
|
name="check"
|
||||||
|
style="color: green; font-size: 21.4em"
|
||||||
|
></q-icon>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
v-if="'{{ charge.webhook }}' != None"
|
||||||
|
type="a"
|
||||||
|
href="{{ charge.completelink }}"
|
||||||
|
label="{{ charge.completelinktext }}"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<center>
|
||||||
|
<span class="text-subtitle2"
|
||||||
|
>Send {{ charge.amount }}sats<br />
|
||||||
|
to this onchain address</span
|
||||||
|
>
|
||||||
|
</center>
|
||||||
|
<a href="bitcoin://{{ charge.onchainaddress }}">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-md">
|
||||||
|
<qrcode
|
||||||
|
:value="'{{ charge.onchainaddress }}'"
|
||||||
|
:options="{width: 800}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText('{{ charge.onchainaddress }}')"
|
||||||
|
>Copy address</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.theCard {
|
||||||
|
width: 360px;
|
||||||
|
margin: 10px auto;
|
||||||
|
}
|
||||||
|
.theHeading {
|
||||||
|
margin: 15px;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
newProgress: 0.4,
|
||||||
|
counter: 1,
|
||||||
|
newTimeLeft: '',
|
||||||
|
timetoComplete: 100,
|
||||||
|
lnbtc: true,
|
||||||
|
onbtc: false,
|
||||||
|
charge_time_elapsed: '{{charge.time_elapsed}}',
|
||||||
|
charge_amount: '{{charge.amount}}',
|
||||||
|
charge_balance: '{{charge.balance}}',
|
||||||
|
charge_paid: '{{charge.paid}}'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
checkBalance: function () {
|
||||||
|
var self = this
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/satspay/api/v1/charges/balance/{{ charge.id }}',
|
||||||
|
'filla'
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.charge_time_elapsed = response.data.time_elapsed
|
||||||
|
self.charge_amount = response.data.amount
|
||||||
|
self.charge_balance = response.data.balance
|
||||||
|
if (self.charge_balance >= self.charge_amount) {
|
||||||
|
self.charge_paid = 'True'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
payLN: function () {
|
||||||
|
this.lnbtc = true
|
||||||
|
this.onbtc = false
|
||||||
|
},
|
||||||
|
payON: function () {
|
||||||
|
this.lnbtc = false
|
||||||
|
this.onbtc = true
|
||||||
|
},
|
||||||
|
getTheTime: function () {
|
||||||
|
var timeToComplete =
|
||||||
|
parseInt('{{ charge.time }}') * 60 -
|
||||||
|
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
|
||||||
|
this.timetoComplete = timeToComplete
|
||||||
|
var timeLeft = Quasar.utils.date.formatDate(
|
||||||
|
new Date((timeToComplete - 3600) * 1000),
|
||||||
|
'HH:mm:ss'
|
||||||
|
)
|
||||||
|
this.newTimeLeft = timeLeft
|
||||||
|
},
|
||||||
|
getThePercentage: function () {
|
||||||
|
var timeToComplete =
|
||||||
|
parseInt('{{ charge.time }}') * 60 -
|
||||||
|
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
|
||||||
|
this.newProgress =
|
||||||
|
1 - timeToComplete / (parseInt('{{ charge.time }}') * 60)
|
||||||
|
},
|
||||||
|
|
||||||
|
timerCount: function () {
|
||||||
|
self = this
|
||||||
|
var refreshIntervalId = setInterval(function () {
|
||||||
|
if (self.charge_paid == 'True' || self.timetoComplete < 1) {
|
||||||
|
clearInterval(refreshIntervalId)
|
||||||
|
}
|
||||||
|
self.getTheTime()
|
||||||
|
self.getThePercentage()
|
||||||
|
self.counter++
|
||||||
|
if (self.counter % 10 === 0) {
|
||||||
|
self.checkBalance()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
console.log('{{ charge.onchainaddress }}' == 'None')
|
||||||
|
if ('{{ charge.lnbitswallet }}' == 'None') {
|
||||||
|
this.lnbtc = false
|
||||||
|
this.onbtc = true
|
||||||
|
}
|
||||||
|
this.getTheTime()
|
||||||
|
this.getThePercentage()
|
||||||
|
var timerCount = this.timerCount
|
||||||
|
if ('{{ charge.paid }}' == 'False') {
|
||||||
|
timerCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
557
lnbits/extensions/satspay/templates/satspay/index.html
Normal file
557
lnbits/extensions/satspay/templates/satspay/index.html
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
{% 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-7 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
{% raw %}
|
||||||
|
<q-btn unelevated color="primary" @click="formDialogCharge.show = true"
|
||||||
|
>New charge
|
||||||
|
</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">Charges</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-input
|
||||||
|
borderless
|
||||||
|
dense
|
||||||
|
debounce="300"
|
||||||
|
v-model="filter"
|
||||||
|
placeholder="Search"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon name="search"></q-icon>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<q-btn flat color="grey" @click="exportchargeCSV"
|
||||||
|
>Export to CSV</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:data="ChargeLinks"
|
||||||
|
row-key="id"
|
||||||
|
:columns="ChargesTable.columns"
|
||||||
|
:pagination.sync="ChargesTable.pagination"
|
||||||
|
:filter="filter"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
|
||||||
|
<q-th
|
||||||
|
v-for="col in props.cols"
|
||||||
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
auto-width
|
||||||
|
>
|
||||||
|
<div v-if="col.name == 'id'"></div>
|
||||||
|
<div v-else>{{ col.label }}</div>
|
||||||
|
</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="link"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="props.row.displayUrl"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<q-tooltip> Payment link </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
|
||||||
|
unelevated
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="error"
|
||||||
|
:color="($q.dark.isActive) ? 'red' : 'red'"
|
||||||
|
>
|
||||||
|
<q-tooltip> Time elapsed </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
v-else-if="props.row.balance >= props.row.amount"
|
||||||
|
unelevated
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="check"
|
||||||
|
:color="($q.dark.isActive) ? 'green' : 'green'"
|
||||||
|
>
|
||||||
|
<q-tooltip> PAID! </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="cached"
|
||||||
|
flat
|
||||||
|
:color="($q.dark.isActive) ? 'blue' : 'blue'"
|
||||||
|
>
|
||||||
|
<q-tooltip> Processing </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteChargeLink(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
>
|
||||||
|
<q-tooltip> Delete charge </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td
|
||||||
|
v-for="col in props.cols"
|
||||||
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
auto-width
|
||||||
|
>
|
||||||
|
<div v-if="col.name == 'id'"></div>
|
||||||
|
<div v-else>{{ col.value }}</div>
|
||||||
|
</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}} satspay Extension
|
||||||
|
</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list> {% include "satspay/_api_docs.html" %} </q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<q-dialog
|
||||||
|
v-model="formDialogCharge.show"
|
||||||
|
position="top"
|
||||||
|
@hide="closeFormDialog"
|
||||||
|
>
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form @submit="sendFormDataCharge" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCharge.data.description"
|
||||||
|
type="text"
|
||||||
|
label="*Description"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCharge.data.amount"
|
||||||
|
type="number"
|
||||||
|
label="*Amount (sats)"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCharge.data.time"
|
||||||
|
type="number"
|
||||||
|
max="1440"
|
||||||
|
label="*Mins valid for (max 1440)"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCharge.data.webhook"
|
||||||
|
type="url"
|
||||||
|
label="Webhook (URL to send transaction data to once paid)"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCharge.data.completelink"
|
||||||
|
type="url"
|
||||||
|
label="Completed button URL"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCharge.data.completelinktext"
|
||||||
|
type="text"
|
||||||
|
label="Completed button text (ie 'Back to merchant')"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div v-if="walletLinks.length > 0">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="formDialogCharge.data.onchain"
|
||||||
|
label="Onchain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<q-checkbox :value="false" label="Onchain" disabled>
|
||||||
|
<q-tooltip>
|
||||||
|
Watch-Only extension MUST be activated and have a wallet
|
||||||
|
</q-tooltip>
|
||||||
|
</q-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div>
|
||||||
|
<q-checkbox
|
||||||
|
v-model="formDialogCharge.data.lnbits"
|
||||||
|
label="LNbits wallet"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="formDialogCharge.data.onchain">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="formDialogCharge.data.onchainwallet"
|
||||||
|
:options="walletLinks"
|
||||||
|
label="Onchain Wallet"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-select
|
||||||
|
v-if="formDialogCharge.data.lnbits"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="formDialogCharge.data.lnbitswallet"
|
||||||
|
:options="g.user.walletOptions"
|
||||||
|
label="Wallet *"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="
|
||||||
|
formDialogCharge.data.time == null ||
|
||||||
|
formDialogCharge.data.amount == null"
|
||||||
|
type="submit"
|
||||||
|
>Create Charge</q-btn
|
||||||
|
>
|
||||||
|
<q-btn @click="cancelCharge" flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<!-- lnbits/static/vendor
|
||||||
|
<script src="/vendor/vue-qrcode@1.0.2/vue-qrcode.min.js"></script> -->
|
||||||
|
<style></style>
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
var mapCharge = obj => {
|
||||||
|
obj._data = _.clone(obj)
|
||||||
|
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
|
||||||
|
obj.time = obj.time + 'mins'
|
||||||
|
|
||||||
|
if (obj.time_elapsed) {
|
||||||
|
obj.date = 'Time elapsed'
|
||||||
|
} else {
|
||||||
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
|
new Date((obj.theTime - 3600) * 1000),
|
||||||
|
'HH:mm:ss'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
obj.displayUrl = ['/satspay/', obj.id].join('')
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
filter: '',
|
||||||
|
watchonlyactive: false,
|
||||||
|
balance: null,
|
||||||
|
checker: null,
|
||||||
|
walletLinks: [],
|
||||||
|
ChargeLinks: [],
|
||||||
|
ChargeLinksObj: [],
|
||||||
|
onchainwallet: '',
|
||||||
|
currentaddress: '',
|
||||||
|
Addresses: {
|
||||||
|
show: false,
|
||||||
|
data: null
|
||||||
|
},
|
||||||
|
mempool: {
|
||||||
|
endpoint: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
ChargesTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'theId',
|
||||||
|
align: 'left',
|
||||||
|
label: 'ID',
|
||||||
|
field: 'id'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Title',
|
||||||
|
field: 'description'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timeleft',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Time left',
|
||||||
|
field: 'date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'time to pay',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Time to Pay',
|
||||||
|
field: 'time'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'amount',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Amount to pay',
|
||||||
|
field: 'amount'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'balance',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Balance',
|
||||||
|
field: 'balance'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'onchain address',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Onchain Address',
|
||||||
|
field: 'onchainaddress'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'LNbits wallet',
|
||||||
|
align: 'left',
|
||||||
|
label: 'LNbits wallet',
|
||||||
|
field: 'lnbitswallet'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Webhook link',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Webhook link',
|
||||||
|
field: 'webhook'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Paid link',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Paid link',
|
||||||
|
field: 'completelink'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
formDialogCharge: {
|
||||||
|
show: false,
|
||||||
|
data: {
|
||||||
|
onchain: false,
|
||||||
|
lnbits: false,
|
||||||
|
description: '',
|
||||||
|
time: null,
|
||||||
|
amount: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
qrCodeDialog: {
|
||||||
|
show: false,
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
cancelCharge: function (data) {
|
||||||
|
var self = this
|
||||||
|
self.formDialogCharge.data.description = ''
|
||||||
|
self.formDialogCharge.data.onchainwallet = ''
|
||||||
|
self.formDialogCharge.data.lnbitswallet = ''
|
||||||
|
self.formDialogCharge.data.time = null
|
||||||
|
self.formDialogCharge.data.amount = null
|
||||||
|
self.formDialogCharge.data.webhook = ''
|
||||||
|
self.formDialogCharge.data.completelink = ''
|
||||||
|
self.formDialogCharge.show = false
|
||||||
|
},
|
||||||
|
|
||||||
|
getWalletLinks: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/watchonly/api/v1/wallet',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
for (i = 0; i < response.data.length; i++) {
|
||||||
|
self.walletLinks.push(response.data[i].id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeFormDialog: function () {
|
||||||
|
this.formDialog.data = {
|
||||||
|
is_unique: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openQrCodeDialog: function (linkId) {
|
||||||
|
var self = this
|
||||||
|
var getAddresses = this.getAddresses
|
||||||
|
getAddresses(linkId)
|
||||||
|
self.current = linkId
|
||||||
|
self.Addresses.show = true
|
||||||
|
},
|
||||||
|
getCharges: function () {
|
||||||
|
var self = this
|
||||||
|
var getAddressBalance = this.getAddressBalance
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/satspay/api/v1/charges',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.ChargeLinks = response.data.map(mapCharge)
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
sendFormDataCharge: function () {
|
||||||
|
var self = this
|
||||||
|
var wallet = this.g.user.wallets[0].adminkey
|
||||||
|
var data = this.formDialogCharge.data
|
||||||
|
data.amount = parseInt(data.amount)
|
||||||
|
data.time = parseInt(data.time)
|
||||||
|
this.createCharge(wallet, data)
|
||||||
|
},
|
||||||
|
timerCount: function () {
|
||||||
|
self = this
|
||||||
|
var refreshIntervalId = setInterval(function () {
|
||||||
|
for (i = 0; i < self.ChargeLinks.length - 1; i++) {
|
||||||
|
if (self.ChargeLinks[i]['paid'] == 'True') {
|
||||||
|
setTimeout(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/satspay/api/v1/charges/balance/' +
|
||||||
|
self.ChargeLinks[i]['id'],
|
||||||
|
'filla'
|
||||||
|
)
|
||||||
|
.then(function (response) {})
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.getCharges()
|
||||||
|
}, 20000)
|
||||||
|
},
|
||||||
|
createCharge: function (wallet, data) {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request('POST', '/satspay/api/v1/charge', wallet, data)
|
||||||
|
.then(function (response) {
|
||||||
|
self.ChargeLinks.push(mapCharge(response.data))
|
||||||
|
self.formDialogCharge.show = false
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteChargeLink: function (chargeId) {
|
||||||
|
var self = this
|
||||||
|
var link = _.findWhere(this.ChargeLinks, {id: chargeId})
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||||
|
.onOk(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/satspay/api/v1/charge/' + chargeId,
|
||||||
|
self.g.user.wallets[0].adminkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.ChargeLinks = _.reject(self.ChargeLinks, function (obj) {
|
||||||
|
return obj.id === chargeId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exportchargeCSV: function () {
|
||||||
|
var self = this
|
||||||
|
LNbits.utils.exportCSV(self.ChargesTable.columns, this.ChargeLinks)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
console.log(this.g.user)
|
||||||
|
var self = this
|
||||||
|
var getCharges = this.getCharges
|
||||||
|
getCharges()
|
||||||
|
var getWalletLinks = this.getWalletLinks
|
||||||
|
getWalletLinks()
|
||||||
|
var timerCount = this.timerCount
|
||||||
|
timerCount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
30
lnbits/extensions/satspay/views.py
Normal file
30
lnbits/extensions/satspay/views.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from fastapi.param_functions import Depends
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
from starlette.requests import Request
|
||||||
|
from lnbits.core.models import User
|
||||||
|
from lnbits.core.crud import get_wallet
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from . import satspay_ext, satspay_renderer
|
||||||
|
from .crud import get_charge
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
@satspay_ext.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
|
return satspay_renderer().TemplateResponse("satspay/index.html", {"request": request,"user": user.dict()})
|
||||||
|
|
||||||
|
|
||||||
|
@satspay_ext.get("/{charge_id}", response_class=HTMLResponse)
|
||||||
|
async def display(request: Request, charge_id):
|
||||||
|
charge = await get_charge(charge_id)
|
||||||
|
if not charge:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Charge link does not exist."
|
||||||
|
)
|
||||||
|
return satspay_renderer().TemplateResponse("satspay/display.html", {"request": request, "charge": charge})
|
142
lnbits/extensions/satspay/views_api.py
Normal file
142
lnbits/extensions/satspay/views_api.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import hashlib
|
||||||
|
|
||||||
|
from http import HTTPStatus
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from fastapi import Query
|
||||||
|
from fastapi.params import Depends
|
||||||
|
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import HTMLResponse, JSONResponse # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_user
|
||||||
|
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||||
|
|
||||||
|
from lnbits.extensions.satspay import satspay_ext
|
||||||
|
from .models import CreateCharge
|
||||||
|
from .crud import (
|
||||||
|
create_charge,
|
||||||
|
update_charge,
|
||||||
|
get_charge,
|
||||||
|
get_charges,
|
||||||
|
delete_charge,
|
||||||
|
check_address_balance,
|
||||||
|
)
|
||||||
|
|
||||||
|
#############################CHARGES##########################
|
||||||
|
|
||||||
|
|
||||||
|
@satspay_ext.post("/api/v1/charge")
|
||||||
|
@satspay_ext.put("/api/v1/charge/{charge_id}")
|
||||||
|
|
||||||
|
async def api_charge_create_or_update(data: CreateCharge, wallet: WalletTypeInfo = Depends(get_key_type), charge_id=None):
|
||||||
|
if not charge_id:
|
||||||
|
charge = await create_charge(user=wallet.wallet.user, data=data)
|
||||||
|
return charge.dict()
|
||||||
|
else:
|
||||||
|
charge = await update_charge(charge_id=charge_id, data=data)
|
||||||
|
return charge.dict()
|
||||||
|
|
||||||
|
|
||||||
|
@satspay_ext.get("/api/v1/charges")
|
||||||
|
async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
try:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
**charge.dict(),
|
||||||
|
**{"time_elapsed": charge.time_elapsed},
|
||||||
|
**{"paid": charge.paid},
|
||||||
|
}
|
||||||
|
for charge in await get_charges(wallet.wallet.user)
|
||||||
|
]
|
||||||
|
except:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@satspay_ext.get("/api/v1/charge/{charge_id}")
|
||||||
|
async def api_charge_retrieve(charge_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
charge = await get_charge(charge_id)
|
||||||
|
|
||||||
|
if not charge:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Charge does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
**charge.dict(),
|
||||||
|
**{"time_elapsed": charge.time_elapsed},
|
||||||
|
**{"paid": charge.paid},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@satspay_ext.delete("/api/v1/charge/{charge_id}")
|
||||||
|
async def api_charge_delete(charge_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
charge = await get_charge(charge_id)
|
||||||
|
|
||||||
|
if not charge:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Charge does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
await delete_charge(charge_id)
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
#############################BALANCE##########################
|
||||||
|
|
||||||
|
|
||||||
|
@satspay_ext.get("/api/v1/charges/balance/{charge_id}")
|
||||||
|
async def api_charges_balance(charge_id):
|
||||||
|
|
||||||
|
charge = await check_address_balance(charge_id)
|
||||||
|
|
||||||
|
if not charge:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Charge does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
if charge.paid and charge.webhook:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
r = await client.post(
|
||||||
|
charge.webhook,
|
||||||
|
json={
|
||||||
|
"id": charge.id,
|
||||||
|
"description": charge.description,
|
||||||
|
"onchainaddress": charge.onchainaddress,
|
||||||
|
"payment_request": charge.payment_request,
|
||||||
|
"payment_hash": charge.payment_hash,
|
||||||
|
"time": charge.time,
|
||||||
|
"amount": charge.amount,
|
||||||
|
"balance": charge.balance,
|
||||||
|
"paid": charge.paid,
|
||||||
|
"timestamp": charge.timestamp,
|
||||||
|
"completelink": charge.completelink,
|
||||||
|
},
|
||||||
|
timeout=40,
|
||||||
|
)
|
||||||
|
except AssertionError:
|
||||||
|
charge.webhook = None
|
||||||
|
return charge.dict()
|
||||||
|
|
||||||
|
|
||||||
|
#############################MEMPOOL##########################
|
||||||
|
|
||||||
|
|
||||||
|
@satspay_ext.put("/api/v1/mempool")
|
||||||
|
async def api_update_mempool(endpoint: str = Query(...), wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
mempool = await update_mempool(endpoint, user=wallet.wallet.user)
|
||||||
|
return mempool.dict()
|
||||||
|
|
||||||
|
|
||||||
|
@satspay_ext.route("/api/v1/mempool")
|
||||||
|
async def api_get_mempool(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
mempool = await get_mempool(wallet.wallet.user)
|
||||||
|
if not mempool:
|
||||||
|
mempool = await create_mempool(user=wallet.wallet.user)
|
||||||
|
return mempool.dict()
|
19
lnbits/extensions/watchonly/README.md
Normal file
19
lnbits/extensions/watchonly/README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Watch Only wallet
|
||||||
|
|
||||||
|
## Monitor an onchain wallet and generate addresses for onchain payments
|
||||||
|
|
||||||
|
Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API.
|
||||||
|
|
||||||
|
1. Start by clicking "NEW WALLET"\
|
||||||
|

|
||||||
|
2. Fill the requested fields:
|
||||||
|
- give the wallet a name
|
||||||
|
- paste an Extended Public Key (xpub, ypub, zpub)
|
||||||
|
- click "CREATE WATCH-ONLY WALLET"\
|
||||||
|

|
||||||
|
3. You can then access your onchain addresses\
|
||||||
|

|
||||||
|
4. You can then generate bitcoin onchain adresses from LNbits\
|
||||||
|

|
||||||
|
|
||||||
|
You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension
|
25
lnbits/extensions/watchonly/__init__.py
Normal file
25
lnbits/extensions/watchonly/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
|
||||||
|
db = Database("ext_watchonly")
|
||||||
|
|
||||||
|
|
||||||
|
watchonly_ext: APIRouter = APIRouter(
|
||||||
|
prefix="/watchonly",
|
||||||
|
tags=["watchonly"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def watchonly_renderer():
|
||||||
|
return template_renderer(
|
||||||
|
[
|
||||||
|
"lnbits/extensions/watchonly/templates",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from .views_api import * # noqa
|
||||||
|
from .views import * # noqa
|
8
lnbits/extensions/watchonly/config.json
Normal file
8
lnbits/extensions/watchonly/config.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "Watch Only",
|
||||||
|
"short_description": "Onchain watch only wallets",
|
||||||
|
"icon": "visibility",
|
||||||
|
"contributors": [
|
||||||
|
"arcbtc"
|
||||||
|
]
|
||||||
|
}
|
214
lnbits/extensions/watchonly/crud.py
Normal file
214
lnbits/extensions/watchonly/crud.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .models import Wallets, Addresses, Mempool
|
||||||
|
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
|
from embit.descriptor import Descriptor, Key # type: ignore
|
||||||
|
from embit.descriptor.arguments import AllowedDerivation # type: ignore
|
||||||
|
from embit.networks import NETWORKS # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
##########################WALLETS####################
|
||||||
|
|
||||||
|
|
||||||
|
def detect_network(k):
|
||||||
|
version = k.key.version
|
||||||
|
for network_name in NETWORKS:
|
||||||
|
net = NETWORKS[network_name]
|
||||||
|
# not found in this network
|
||||||
|
if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]:
|
||||||
|
return net
|
||||||
|
|
||||||
|
|
||||||
|
def parse_key(masterpub: str):
|
||||||
|
"""Parses masterpub or descriptor and returns a tuple: (Descriptor, network)
|
||||||
|
To create addresses use descriptor.derive(num).address(network=network)
|
||||||
|
"""
|
||||||
|
network = None
|
||||||
|
# probably a single key
|
||||||
|
if "(" not in masterpub:
|
||||||
|
k = Key.from_string(masterpub)
|
||||||
|
if not k.is_extended:
|
||||||
|
raise ValueError("The key is not a master public key")
|
||||||
|
if k.is_private:
|
||||||
|
raise ValueError("Private keys are not allowed")
|
||||||
|
# check depth
|
||||||
|
if k.key.depth != 3:
|
||||||
|
raise ValueError(
|
||||||
|
"Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors."
|
||||||
|
)
|
||||||
|
# if allowed derivation is not provided use default /{0,1}/*
|
||||||
|
if k.allowed_derivation is None:
|
||||||
|
k.allowed_derivation = AllowedDerivation.default()
|
||||||
|
# get version bytes
|
||||||
|
version = k.key.version
|
||||||
|
for network_name in NETWORKS:
|
||||||
|
net = NETWORKS[network_name]
|
||||||
|
# not found in this network
|
||||||
|
if version in [net["xpub"], net["ypub"], net["zpub"]]:
|
||||||
|
network = net
|
||||||
|
if version == net["xpub"]:
|
||||||
|
desc = Descriptor.from_string("pkh(%s)" % str(k))
|
||||||
|
elif version == net["ypub"]:
|
||||||
|
desc = Descriptor.from_string("sh(wpkh(%s))" % str(k))
|
||||||
|
elif version == net["zpub"]:
|
||||||
|
desc = Descriptor.from_string("wpkh(%s)" % str(k))
|
||||||
|
break
|
||||||
|
# we didn't find correct version
|
||||||
|
if network is None:
|
||||||
|
raise ValueError("Unknown master public key version")
|
||||||
|
else:
|
||||||
|
desc = Descriptor.from_string(masterpub)
|
||||||
|
if not desc.is_wildcard:
|
||||||
|
raise ValueError("Descriptor should have wildcards")
|
||||||
|
for k in desc.keys:
|
||||||
|
if k.is_extended:
|
||||||
|
net = detect_network(k)
|
||||||
|
if net is None:
|
||||||
|
raise ValueError(f"Unknown version: {k}")
|
||||||
|
if network is not None and network != net:
|
||||||
|
raise ValueError("Keys from different networks")
|
||||||
|
network = net
|
||||||
|
return desc, network
|
||||||
|
|
||||||
|
|
||||||
|
async def create_watch_wallet(user: str, masterpub: str, title: str) -> Wallets:
|
||||||
|
# check the masterpub is fine, it will raise an exception if not
|
||||||
|
print("PARSE", parse_key(masterpub))
|
||||||
|
parse_key(masterpub)
|
||||||
|
wallet_id = urlsafe_short_hash()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO watchonly.wallets (
|
||||||
|
id,
|
||||||
|
"user",
|
||||||
|
masterpub,
|
||||||
|
title,
|
||||||
|
address_no,
|
||||||
|
balance
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
# address_no is -1 so fresh address on empty wallet can get address with index 0
|
||||||
|
(wallet_id, user, masterpub, title, -1, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
return await get_watch_wallet(wallet_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_watch_wallet(wallet_id: str) -> Optional[Wallets]:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,)
|
||||||
|
)
|
||||||
|
return Wallets.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_watch_wallets(user: str) -> List[Wallets]:
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"""SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,)
|
||||||
|
)
|
||||||
|
return [Wallets(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]:
|
||||||
|
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE watchonly.wallets SET {q} WHERE id = ?", (*kwargs.values(), wallet_id)
|
||||||
|
)
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,)
|
||||||
|
)
|
||||||
|
return Wallets.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_watch_wallet(wallet_id: str) -> None:
|
||||||
|
await db.execute("DELETE FROM watchonly.wallets WHERE id = ?", (wallet_id,))
|
||||||
|
|
||||||
|
########################ADDRESSES#######################
|
||||||
|
|
||||||
|
|
||||||
|
async def get_derive_address(wallet_id: str, num: int):
|
||||||
|
wallet = await get_watch_wallet(wallet_id)
|
||||||
|
key = wallet.masterpub
|
||||||
|
desc, network = parse_key(key)
|
||||||
|
return desc.derive(num).address(network=network)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fresh_address(wallet_id: str) -> Optional[Addresses]:
|
||||||
|
wallet = await get_watch_wallet(wallet_id)
|
||||||
|
|
||||||
|
if not wallet:
|
||||||
|
return None
|
||||||
|
|
||||||
|
address = await get_derive_address(wallet_id, wallet.address_no + 1)
|
||||||
|
|
||||||
|
await update_watch_wallet(wallet_id=wallet_id, address_no=wallet.address_no + 1)
|
||||||
|
masterpub_id = urlsafe_short_hash()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO watchonly.addresses (
|
||||||
|
id,
|
||||||
|
address,
|
||||||
|
wallet,
|
||||||
|
amount
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(masterpub_id, address, wallet_id, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
return await get_address(address)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_address(address: str) -> Optional[Addresses]:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM watchonly.addresses WHERE address = ?", (address,)
|
||||||
|
)
|
||||||
|
return Addresses.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_addresses(wallet_id: str) -> List[Addresses]:
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT * FROM watchonly.addresses WHERE wallet = ?", (wallet_id,)
|
||||||
|
)
|
||||||
|
return [Addresses(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
######################MEMPOOL#######################
|
||||||
|
|
||||||
|
|
||||||
|
async def create_mempool(user: str) -> Optional[Mempool]:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO watchonly.mempool ("user",endpoint)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""",
|
||||||
|
(user, "https://mempool.space"),
|
||||||
|
)
|
||||||
|
row = await db.fetchone(
|
||||||
|
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
|
||||||
|
)
|
||||||
|
return Mempool.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def update_mempool(user: str, **kwargs) -> Optional[Mempool]:
|
||||||
|
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
f"""UPDATE watchonly.mempool SET {q} WHERE "user" = ?""",
|
||||||
|
(*kwargs.values(), user),
|
||||||
|
)
|
||||||
|
row = await db.fetchone(
|
||||||
|
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
|
||||||
|
)
|
||||||
|
return Mempool.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_mempool(user: str) -> Mempool:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
|
||||||
|
)
|
||||||
|
return Mempool.from_row(row) if row else None
|
36
lnbits/extensions/watchonly/migrations.py
Normal file
36
lnbits/extensions/watchonly/migrations.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial wallet table.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE watchonly.wallets (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"user" TEXT,
|
||||||
|
masterpub TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
address_no INTEGER NOT NULL DEFAULT 0,
|
||||||
|
balance INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE watchonly.addresses (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
amount INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE watchonly.mempool (
|
||||||
|
"user" TEXT NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
39
lnbits/extensions/watchonly/models.py
Normal file
39
lnbits/extensions/watchonly/models.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from sqlite3 import Row
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class CreateWallet(BaseModel):
|
||||||
|
masterpub: str = Query("")
|
||||||
|
title: str = Query("")
|
||||||
|
|
||||||
|
class Wallets(BaseModel):
|
||||||
|
id: str
|
||||||
|
user: str
|
||||||
|
masterpub: str
|
||||||
|
title: str
|
||||||
|
address_no: int
|
||||||
|
balance: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "Wallets":
|
||||||
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
class Mempool(BaseModel):
|
||||||
|
user: str
|
||||||
|
endpoint: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "Mempool":
|
||||||
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
class Addresses(BaseModel):
|
||||||
|
id: str
|
||||||
|
address: str
|
||||||
|
wallet: str
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "Addresses":
|
||||||
|
return cls(**dict(row))
|
244
lnbits/extensions/watchonly/templates/watchonly/_api_docs.html
Normal file
244
lnbits/extensions/watchonly/templates/watchonly/_api_docs.html
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<p>
|
||||||
|
Watch Only extension uses mempool.space<br />
|
||||||
|
For use with "account Extended Public Key"
|
||||||
|
<a href="https://iancoleman.io/bip39/">https://iancoleman.io/bip39/</a>
|
||||||
|
<small>
|
||||||
|
<br />Created by,
|
||||||
|
<a target="_blank" href="https://github.com/arcbtc">Ben Arc</a> (using,
|
||||||
|
<a target="_blank" href="https://github.com/diybitcoinhardware/embit"
|
||||||
|
>Embit</a
|
||||||
|
></small
|
||||||
|
>)
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<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 wallets">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span> /watchonly/api/v1/wallet</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>[<wallets_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}api/v1/wallet -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Get wallet details"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span>
|
||||||
|
/watchonly/api/v1/wallet/<wallet_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 201 CREATED (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<wallet_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}api/v1/wallet/<wallet_id>
|
||||||
|
-H "X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Create wallet">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-green">POST</span> /watchonly/api/v1/wallet</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">
|
||||||
|
Body (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 201 CREATED (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<wallet_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.url_root }}api/v1/wallet -d '{"title":
|
||||||
|
<string>, "masterpub": <string>}' -H "Content-type:
|
||||||
|
application/json" -H "X-Api-Key: {{ user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Delete wallet"
|
||||||
|
class="q-pb-md"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-pink">DELETE</span>
|
||||||
|
/watchonly/api/v1/wallet/<wallet_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.url_root
|
||||||
|
}}api/v1/wallet/<wallet_id> -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="List addresses">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span>
|
||||||
|
/watchonly/api/v1/addresses/<wallet_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>[<address_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root
|
||||||
|
}}api/v1/addresses/<wallet_id> -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Get fresh address"
|
||||||
|
class="q-pb-md"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span>
|
||||||
|
/watchonly/api/v1/address/<wallet_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>[<address_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}api/v1/address/<wallet_id>
|
||||||
|
-H "X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Get mempool.space details"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span> /watchonly/api/v1/mempool</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">
|
||||||
|
Body (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<mempool_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}api/v1/mempool -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Update mempool.space"
|
||||||
|
class="q-pb-md"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-green">POST</span>
|
||||||
|
/watchonly/api/v1/mempool</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">
|
||||||
|
Body (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 201 CREATED (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<mempool_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X PUT {{ request.url_root }}api/v1/mempool -d '{"endpoint":
|
||||||
|
<string>}' -H "Content-type: application/json" -H "X-Api-Key:
|
||||||
|
{{ user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-card>
|
648
lnbits/extensions/watchonly/templates/watchonly/index.html
Normal file
648
lnbits/extensions/watchonly/templates/watchonly/index.html
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
{% 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-7 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
{% raw %}
|
||||||
|
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||||
|
>New wallet
|
||||||
|
</q-btn>
|
||||||
|
<q-btn unelevated color="primary" icon="edit">
|
||||||
|
<div class="cursor-pointer">
|
||||||
|
<q-tooltip> Point to another Mempool </q-tooltip>
|
||||||
|
{{ this.mempool.endpoint }}
|
||||||
|
<q-popup-edit v-model="mempool.endpoint">
|
||||||
|
<q-input color="accent" v-model="mempool.endpoint"> </q-input>
|
||||||
|
<center>
|
||||||
|
<q-btn flat dense @click="updateMempool()" v-close-popup
|
||||||
|
>set</q-btn
|
||||||
|
>
|
||||||
|
<q-btn flat dense v-close-popup>cancel</q-btn>
|
||||||
|
</center>
|
||||||
|
</q-popup-edit>
|
||||||
|
</div>
|
||||||
|
</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">Wallets</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-input
|
||||||
|
borderless
|
||||||
|
dense
|
||||||
|
debounce="300"
|
||||||
|
v-model="filter"
|
||||||
|
placeholder="Search"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon name="search"></q-icon>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:data="walletLinks"
|
||||||
|
row-key="id"
|
||||||
|
:columns="WalletsTable.columns"
|
||||||
|
:pagination.sync="WalletsTable.pagination"
|
||||||
|
:filter="filter"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
auto-width
|
||||||
|
>
|
||||||
|
{{ 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="dns"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
@click="openQrCodeDialog(props.row.id)"
|
||||||
|
>
|
||||||
|
<q-tooltip> Adresses </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteWalletLink(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td
|
||||||
|
v-for="col in props.cols"
|
||||||
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
auto-width
|
||||||
|
>
|
||||||
|
{{ col.value }}
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<div class="row justify-center q-gutter-x-md items-center">
|
||||||
|
<div class="text-h3 q-pa-sm">{{satBtc(utxos.total)}}</div>
|
||||||
|
<q-btn flat @click="utxos.sats = !utxos.sats">
|
||||||
|
{{utxos.sats ? ' sats' : ' BTC'}}</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</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">Transactions</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-input
|
||||||
|
borderless
|
||||||
|
dense
|
||||||
|
debounce="300"
|
||||||
|
v-model="TxosTable.filter"
|
||||||
|
placeholder="Search"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon name="search"></q-icon>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:data="utxos.data"
|
||||||
|
row-key="txid"
|
||||||
|
:columns="TxosTable.columns"
|
||||||
|
:pagination.sync="TxosTable.pagination"
|
||||||
|
:filter="TxosTable.filter"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th
|
||||||
|
v-for="col in props.cols"
|
||||||
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
auto-width
|
||||||
|
>
|
||||||
|
{{ col.label }}
|
||||||
|
</q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td
|
||||||
|
v-for="col in props.cols"
|
||||||
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
auto-width
|
||||||
|
:class="col.value == true ? 'text-green-13 text-weight-bold' : ''"
|
||||||
|
>
|
||||||
|
{{ col.name == 'value' ? satBtc(col.value) : col.value }}
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
|
||||||
|
<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}} Watch Only Extension
|
||||||
|
</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list> {% include "watchonly/_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-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.title"
|
||||||
|
type="text"
|
||||||
|
label="Title"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
type="textarea"
|
||||||
|
v-model="formDialog.data.masterpub"
|
||||||
|
height="50px"
|
||||||
|
autogrow
|
||||||
|
label="Account Extended Public Key; xpub, ypub, zpub; Bitcoin Descriptor"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="
|
||||||
|
formDialog.data.masterpub == null ||
|
||||||
|
formDialog.data.title == null"
|
||||||
|
type="submit"
|
||||||
|
>Create Watch-only Wallet</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="Addresses.show" position="top">
|
||||||
|
<q-card v-if="Addresses.data" class="q-pa-lg lnbits__dialog-card">
|
||||||
|
{% raw %}
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Addresses</h5>
|
||||||
|
<q-separator></q-separator><br />
|
||||||
|
<p>
|
||||||
|
<strong>Current:</strong>
|
||||||
|
{{ currentaddress }}
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="ms"
|
||||||
|
icon="visibility"
|
||||||
|
type="a"
|
||||||
|
:href="mempool.endpoint + '/address/' + currentaddress"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</p>
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||||
|
<qrcode
|
||||||
|
:value="currentaddress"
|
||||||
|
:options="{width: 800}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
<p style="word-break: break-all">
|
||||||
|
<q-scroll-area style="height: 200px; max-width: 100%">
|
||||||
|
<q-list bordered v-for="data in Addresses.data.slice().reverse()">
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>{{ data.address }}</q-item-section>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="ms"
|
||||||
|
icon="visibility"
|
||||||
|
type="a"
|
||||||
|
:href="mempool.endpoint + '/address/' + data.address"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-scroll-area>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg q-gutter-sm">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="getFreshAddress(current)"
|
||||||
|
class="q-ml-sm"
|
||||||
|
>Get fresh address</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
{% endraw %}
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script type="text/javascript" src="https://mempool.space/mempool.js"></script>
|
||||||
|
<style></style>
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
Vue.filter('reverse', function (value) {
|
||||||
|
// slice to make a copy of array, then reverse the copy
|
||||||
|
return value.slice().reverse()
|
||||||
|
})
|
||||||
|
var locationPath = [
|
||||||
|
window.location.protocol,
|
||||||
|
'//',
|
||||||
|
window.location.hostname,
|
||||||
|
window.location.pathname
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
var mapWalletLink = function (obj) {
|
||||||
|
obj._data = _.clone(obj)
|
||||||
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
|
new Date(obj.time * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
var mapAddresses = function (obj) {
|
||||||
|
obj._data = _.clone(obj)
|
||||||
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
|
new Date(obj.time * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
filter: '',
|
||||||
|
balance: null,
|
||||||
|
checker: null,
|
||||||
|
walletLinks: [],
|
||||||
|
AddressesLinks: [],
|
||||||
|
currentaddress: '',
|
||||||
|
Addresses: {
|
||||||
|
show: false,
|
||||||
|
data: null
|
||||||
|
},
|
||||||
|
utxos: {
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
sats: true
|
||||||
|
},
|
||||||
|
mempool: {
|
||||||
|
endpoint: ''
|
||||||
|
},
|
||||||
|
WalletsTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Title',
|
||||||
|
field: 'title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'masterpub',
|
||||||
|
align: 'left',
|
||||||
|
label: 'MasterPub',
|
||||||
|
field: 'masterpub'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TxosTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Value',
|
||||||
|
field: 'value',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'confirmed',
|
||||||
|
align: 'center',
|
||||||
|
label: 'Confirmed',
|
||||||
|
field: 'confirmed',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'date',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Date',
|
||||||
|
field: 'date',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'txid',
|
||||||
|
align: 'left',
|
||||||
|
label: 'ID',
|
||||||
|
field: 'txid',
|
||||||
|
sortable: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
},
|
||||||
|
uxtosFilter: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
formDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
qrCodeDialog: {
|
||||||
|
show: false,
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
getAddressDetails: function (address) {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/watchonly/api/v1/mempool/' + address,
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
return reponse.data
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getAddresses: function (walletID) {
|
||||||
|
var self = this
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/watchonly/api/v1/addresses/' + walletID,
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.Addresses.data = response.data
|
||||||
|
self.currentaddress =
|
||||||
|
self.Addresses.data[self.Addresses.data.length - 1].address
|
||||||
|
self.AddressesLinks = response.data.map(function (obj) {
|
||||||
|
return mapAddresses(obj)
|
||||||
|
})
|
||||||
|
self.fetchUtxos()
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getFreshAddress: function (walletID) {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/watchonly/api/v1/address/' + walletID,
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.Addresses.data = response.data
|
||||||
|
self.currentaddress =
|
||||||
|
self.Addresses.data[self.Addresses.data.length - 1].address
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getMempool: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/watchonly/api/v1/mempool',
|
||||||
|
this.g.user.wallets[0].adminkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.mempool.endpoint = response.data.endpoint
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMempool: function () {
|
||||||
|
var self = this
|
||||||
|
var wallet = this.g.user.wallets[0]
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/watchonly/api/v1/mempool',
|
||||||
|
wallet.adminkey,
|
||||||
|
self.mempool
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.mempool.endpoint = response.data.endpoint
|
||||||
|
self.walletLinks.push(mapwalletLink(response.data))
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fetchUtxos: async function () {
|
||||||
|
const {
|
||||||
|
bitcoin: {addresses}
|
||||||
|
} = mempoolJS()
|
||||||
|
const address = this.AddressesLinks.map(x => x.address)
|
||||||
|
const fetchUtxo = async () => {
|
||||||
|
let txs = []
|
||||||
|
// const address = [
|
||||||
|
// '3Er3w82WqPLL4ew23taUZcFQwbZq6PE9TK',
|
||||||
|
// '16CwtWRwQYLojaVZZFCgnaM6SQuNefqwrc',
|
||||||
|
// '1KZB6FqnnMWySk75uvFKuPzHct1tMXHSSR'
|
||||||
|
// ] //test addresses
|
||||||
|
for (add of address) {
|
||||||
|
let addressTxsUtxo = await addresses.getAddressTxsUtxo({
|
||||||
|
address: add
|
||||||
|
})
|
||||||
|
txs = [...txs, ...addressTxsUtxo]
|
||||||
|
}
|
||||||
|
return txs
|
||||||
|
}
|
||||||
|
let utxos = await fetchUtxo()
|
||||||
|
utxos = utxos
|
||||||
|
.reduce((ac, x) => {
|
||||||
|
if (!ac)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
txid: x.txid,
|
||||||
|
confirmed: x.status.confirmed,
|
||||||
|
value: x.value,
|
||||||
|
date: moment(x.status?.block_time * 1000).format('LLL'),
|
||||||
|
sort: x.status?.block_time
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if (!ac.some(y => y.txid == x.txid))
|
||||||
|
return [
|
||||||
|
...ac,
|
||||||
|
{
|
||||||
|
txid: x.txid,
|
||||||
|
confirmed: x.status.confirmed,
|
||||||
|
value: x.value,
|
||||||
|
date: moment(x.status?.block_time * 1000).format('LLL'),
|
||||||
|
sort: x.status?.block_time
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return
|
||||||
|
}, [])
|
||||||
|
.sort((a, b) => b.sort - a.sort)
|
||||||
|
let total = utxos.reduce((total, y) => {
|
||||||
|
total += y?.value || 0
|
||||||
|
return total
|
||||||
|
}, 0)
|
||||||
|
this.utxos.data = utxos
|
||||||
|
this.utxos.total = total
|
||||||
|
},
|
||||||
|
getWalletLinks: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/watchonly/api/v1/wallet',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.walletLinks = response.data.map(function (obj) {
|
||||||
|
self.getAddresses(obj.id)
|
||||||
|
return mapWalletLink(obj)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
closeFormDialog: function () {
|
||||||
|
this.formDialog.data = {
|
||||||
|
is_unique: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openQrCodeDialog: function (linkId) {
|
||||||
|
var self = this
|
||||||
|
var getAddresses = this.getAddresses
|
||||||
|
this.getAddresses(linkId)
|
||||||
|
self.current = linkId
|
||||||
|
self.Addresses.show = true
|
||||||
|
},
|
||||||
|
sendFormData: function () {
|
||||||
|
var wallet = this.g.user.wallets[0]
|
||||||
|
var data = _.omit(this.formDialog.data, 'wallet')
|
||||||
|
this.createWalletLink(wallet, data)
|
||||||
|
},
|
||||||
|
createWalletLink: function (wallet, data) {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request('POST', '/watchonly/api/v1/wallet', wallet.adminkey, data)
|
||||||
|
.then(function (response) {
|
||||||
|
self.walletLinks.push(mapWalletLink(response.data))
|
||||||
|
self.formDialog.show = false
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteWalletLink: function (linkId) {
|
||||||
|
var self = this
|
||||||
|
var link = _.findWhere(this.walletLinks, {id: linkId})
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||||
|
.onOk(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/watchonly/api/v1/wallet/' + linkId,
|
||||||
|
self.g.user.wallets[0].adminkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.walletLinks = _.reject(self.walletLinks, function (obj) {
|
||||||
|
return obj.id === linkId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exportCSV: function () {
|
||||||
|
LNbits.utils.exportCSV(this.paywallsTable.columns, this.paywalls)
|
||||||
|
},
|
||||||
|
satBtc(val) {
|
||||||
|
return this.utxos.sats
|
||||||
|
? LNbits.utils.formatSat(val)
|
||||||
|
: val == 0
|
||||||
|
? 0.0
|
||||||
|
: (val / 100000000).toFixed(8)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
if (this.g.user.wallets.length) {
|
||||||
|
var getMempool = this.getMempool
|
||||||
|
getMempool()
|
||||||
|
var getWalletLinks = this.getWalletLinks
|
||||||
|
getWalletLinks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
32
lnbits/extensions/watchonly/views.py
Normal file
32
lnbits/extensions/watchonly/views.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from http import HTTPStatus
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
from starlette.requests import Request
|
||||||
|
from fastapi.params import Depends
|
||||||
|
|
||||||
|
from lnbits.core.models import User
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
|
||||||
|
from . import watchonly_ext, watchonly_renderer
|
||||||
|
# from .crud import get_payment
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@watchonly_ext.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
|
return watchonly_renderer().TemplateResponse("watchonly/index.html", {"request": request,"user": user.dict()})
|
||||||
|
|
||||||
|
|
||||||
|
# @watchonly_ext.get("/{charge_id}", response_class=HTMLResponse)
|
||||||
|
# async def display(request: Request, charge_id):
|
||||||
|
# link = get_payment(charge_id)
|
||||||
|
# if not link:
|
||||||
|
# raise HTTPException(
|
||||||
|
# status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
# detail="Charge link does not exist."
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# return watchonly_renderer().TemplateResponse("watchonly/display.html", {"request": request,"link": link.dict()})
|
132
lnbits/extensions/watchonly/views_api.py
Normal file
132
lnbits/extensions/watchonly/views_api.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import hashlib
|
||||||
|
from http import HTTPStatus
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_user
|
||||||
|
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||||
|
|
||||||
|
from fastapi import Query
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from .models import CreateWallet
|
||||||
|
|
||||||
|
from lnbits.extensions.watchonly import watchonly_ext
|
||||||
|
from .crud import (
|
||||||
|
create_watch_wallet,
|
||||||
|
get_watch_wallet,
|
||||||
|
get_watch_wallets,
|
||||||
|
update_watch_wallet,
|
||||||
|
delete_watch_wallet,
|
||||||
|
get_fresh_address,
|
||||||
|
get_addresses,
|
||||||
|
create_mempool,
|
||||||
|
update_mempool,
|
||||||
|
get_mempool,
|
||||||
|
)
|
||||||
|
|
||||||
|
###################WALLETS#############################
|
||||||
|
|
||||||
|
|
||||||
|
@watchonly_ext.get("/api/v1/wallet")
|
||||||
|
async def api_wallets_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
|
||||||
|
try:
|
||||||
|
return [wallet.dict() for wallet in await get_watch_wallets(wallet.wallet.user)]
|
||||||
|
except:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@watchonly_ext.get("/api/v1/wallet/{wallet_id}")
|
||||||
|
async def api_wallet_retrieve(wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
w_wallet = await get_watch_wallet(wallet_id)
|
||||||
|
|
||||||
|
if not w_wallet:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Wallet does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
return w_wallet.dict()
|
||||||
|
|
||||||
|
|
||||||
|
@watchonly_ext.post("/api/v1/wallet")
|
||||||
|
async def api_wallet_create_or_update(data: CreateWallet, wallet_id=None, w: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
try:
|
||||||
|
wallet = await create_watch_wallet(
|
||||||
|
user=w.wallet.user, masterpub=data.masterpub, title=data.title
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
mempool = await get_mempool(w.wallet.user)
|
||||||
|
if not mempool:
|
||||||
|
create_mempool(user=w.wallet.user)
|
||||||
|
return wallet.dict()
|
||||||
|
|
||||||
|
|
||||||
|
@watchonly_ext.delete("/api/v1/wallet/{wallet_id}")
|
||||||
|
async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
wallet = await get_watch_wallet(wallet_id)
|
||||||
|
|
||||||
|
if not wallet:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Wallet does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
await delete_watch_wallet(wallet_id)
|
||||||
|
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
#############################ADDRESSES##########################
|
||||||
|
|
||||||
|
|
||||||
|
@watchonly_ext.get("/api/v1/address/{wallet_id}")
|
||||||
|
async def api_fresh_address(wallet_id, w: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
await get_fresh_address(wallet_id)
|
||||||
|
|
||||||
|
addresses = await get_addresses(wallet_id)
|
||||||
|
|
||||||
|
return [address.dict() for address in addresses]
|
||||||
|
|
||||||
|
|
||||||
|
@watchonly_ext.get("/api/v1/addresses/{wallet_id}")
|
||||||
|
|
||||||
|
async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
wallet = await get_watch_wallet(wallet_id)
|
||||||
|
|
||||||
|
if not wallet:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Wallet does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
addresses = await get_addresses(wallet_id)
|
||||||
|
|
||||||
|
if not addresses:
|
||||||
|
await get_fresh_address(wallet_id)
|
||||||
|
addresses = await get_addresses(wallet_id)
|
||||||
|
|
||||||
|
return [address.dict() for address in addresses]
|
||||||
|
|
||||||
|
|
||||||
|
#############################MEMPOOL##########################
|
||||||
|
|
||||||
|
|
||||||
|
@watchonly_ext.put("/api/v1/mempool")
|
||||||
|
async def api_update_mempool(endpoint: str = Query(...), w: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
mempool = await update_mempool(endpoint, user=w.wallet.user)
|
||||||
|
return mempool.dict()
|
||||||
|
|
||||||
|
|
||||||
|
@watchonly_ext.get("/api/v1/mempool")
|
||||||
|
async def api_get_mempool(w: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
mempool = await get_mempool(w.wallet.user)
|
||||||
|
if not mempool:
|
||||||
|
mempool = await create_mempool(user=w.wallet.user)
|
||||||
|
return mempool.dict()
|
@@ -1,11 +1,11 @@
|
|||||||
|
from fastapi.param_functions import Query
|
||||||
|
from fastapi import HTTPException
|
||||||
import shortuuid # type: ignore
|
import shortuuid # type: ignore
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from lnbits.core.services import pay_invoice
|
from lnbits.core.services import pay_invoice
|
||||||
from fastapi.param_functions import Query
|
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.exceptions import HTTPException
|
|
||||||
|
|
||||||
from . import withdraw_ext
|
from . import withdraw_ext
|
||||||
from .crud import get_withdraw_link_by_hash, update_withdraw_link
|
from .crud import get_withdraw_link_by_hash, update_withdraw_link
|
||||||
@@ -39,60 +39,19 @@ async def api_lnurl_response(request: Request, unique_hash):
|
|||||||
return link.lnurl_response(request).dict()
|
return link.lnurl_response(request).dict()
|
||||||
|
|
||||||
|
|
||||||
# FOR LNURLs WHICH ARE UNIQUE
|
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext.get("/api/v1/lnurl/{unique_hash}/{id_unique_hash}", status_code=HTTPStatus.OK, name="withdraw.api_lnurl_multi_response")
|
|
||||||
async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash):
|
|
||||||
link = await get_withdraw_link_by_hash(unique_hash)
|
|
||||||
|
|
||||||
if not link:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail="LNURL-withdraw not found."
|
|
||||||
)
|
|
||||||
# return (
|
|
||||||
# {"status": "ERROR", "reason": "LNURL-withdraw not found."},
|
|
||||||
# HTTPStatus.OK,
|
|
||||||
# )
|
|
||||||
|
|
||||||
if link.is_spent:
|
|
||||||
raise HTTPException(
|
|
||||||
# WHAT STATUS_CODE TO USE??
|
|
||||||
detail="Withdraw is spent."
|
|
||||||
)
|
|
||||||
# return (
|
|
||||||
# {"status": "ERROR", "reason": "Withdraw is spent."},
|
|
||||||
# HTTPStatus.OK,
|
|
||||||
# )
|
|
||||||
|
|
||||||
useslist = link.usescsv.split(",")
|
|
||||||
found = False
|
|
||||||
for x in useslist:
|
|
||||||
tohash = link.id + link.unique_hash + str(x)
|
|
||||||
if id_unique_hash == shortuuid.uuid(name=tohash):
|
|
||||||
found = True
|
|
||||||
if not found:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail="LNURL-withdraw not found."
|
|
||||||
)
|
|
||||||
# return (
|
|
||||||
# {"status": "ERROR", "reason": "LNURL-withdraw not found."},
|
|
||||||
# HTTPStatus.OK,
|
|
||||||
# )
|
|
||||||
|
|
||||||
return link.lnurl_response(req=request).dict()
|
|
||||||
|
|
||||||
|
|
||||||
# CALLBACK
|
# CALLBACK
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", status_code=HTTPStatus.OK, name="withdraw.api_lnurl_callback")
|
@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback")
|
||||||
async def api_lnurl_callback(unique_hash, k1: str = Query(...), pr: str = Query(...)):
|
async def api_lnurl_callback(request: Request,
|
||||||
|
unique_hash: str=Query(...),
|
||||||
|
k1: str = Query(...),
|
||||||
|
payment_request: str = Query(..., alias="pr")
|
||||||
|
):
|
||||||
link = await get_withdraw_link_by_hash(unique_hash)
|
link = await get_withdraw_link_by_hash(unique_hash)
|
||||||
payment_request = pr
|
|
||||||
now = int(datetime.now().timestamp())
|
now = int(datetime.now().timestamp())
|
||||||
|
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
@@ -163,3 +122,48 @@ async def api_lnurl_callback(unique_hash, k1: str = Query(...), pr: str = Query(
|
|||||||
return {"status": "ERROR", "reason": str(e)}
|
return {"status": "ERROR", "reason": str(e)}
|
||||||
|
|
||||||
return {"status": "OK"}
|
return {"status": "OK"}
|
||||||
|
|
||||||
|
# FOR LNURLs WHICH ARE UNIQUE
|
||||||
|
|
||||||
|
|
||||||
|
@withdraw_ext.get("/api/v1/lnurl/{unique_hash}/{id_unique_hash}", status_code=HTTPStatus.OK, name="withdraw.api_lnurl_multi_response")
|
||||||
|
async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash):
|
||||||
|
link = await get_withdraw_link_by_hash(unique_hash)
|
||||||
|
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="LNURL-withdraw not found."
|
||||||
|
)
|
||||||
|
# return (
|
||||||
|
# {"status": "ERROR", "reason": "LNURL-withdraw not found."},
|
||||||
|
# HTTPStatus.OK,
|
||||||
|
# )
|
||||||
|
|
||||||
|
if link.is_spent:
|
||||||
|
raise HTTPException(
|
||||||
|
# WHAT STATUS_CODE TO USE??
|
||||||
|
detail="Withdraw is spent."
|
||||||
|
)
|
||||||
|
# return (
|
||||||
|
# {"status": "ERROR", "reason": "Withdraw is spent."},
|
||||||
|
# HTTPStatus.OK,
|
||||||
|
# )
|
||||||
|
|
||||||
|
useslist = link.usescsv.split(",")
|
||||||
|
found = False
|
||||||
|
for x in useslist:
|
||||||
|
tohash = link.id + link.unique_hash + str(x)
|
||||||
|
if id_unique_hash == shortuuid.uuid(name=tohash):
|
||||||
|
found = True
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="LNURL-withdraw not found."
|
||||||
|
)
|
||||||
|
# return (
|
||||||
|
# {"status": "ERROR", "reason": "LNURL-withdraw not found."},
|
||||||
|
# HTTPStatus.OK,
|
||||||
|
# )
|
||||||
|
|
||||||
|
return link.lnurl_response(request).dict()
|
||||||
|
Reference in New Issue
Block a user