Merge pull request #728 from motorina0/ext_satspay_onchain_fix

`satspay` extension improvements
This commit is contained in:
Arc
2022-07-28 14:46:08 +01:00
committed by GitHub
13 changed files with 890 additions and 783 deletions

View File

@@ -1,6 +1,7 @@
import asyncio import asyncio
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
@@ -11,6 +12,14 @@ db = Database("ext_satspay")
satspay_ext: APIRouter = APIRouter(prefix="/satspay", tags=["satspay"]) satspay_ext: APIRouter = APIRouter(prefix="/satspay", tags=["satspay"])
satspay_static_files = [
{
"path": "/satspay/static",
"app": StaticFiles(directory="lnbits/extensions/satspay/static"),
"name": "satspay_static",
}
]
def satspay_renderer(): def satspay_renderer():
return template_renderer(["lnbits/extensions/satspay/templates"]) return template_renderer(["lnbits/extensions/satspay/templates"])

View File

@@ -6,7 +6,7 @@ from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment from lnbits.core.views.api import api_payment
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from ..watchonly.crud import get_fresh_address, get_mempool, get_watch_wallet from ..watchonly.crud import get_config, get_fresh_address
# from lnbits.db import open_ext_db # from lnbits.db import open_ext_db
from . import db from . import db
@@ -18,7 +18,6 @@ from .models import Charges, CreateCharge
async def create_charge(user: str, data: CreateCharge) -> Charges: async def create_charge(user: str, data: CreateCharge) -> Charges:
charge_id = urlsafe_short_hash() charge_id = urlsafe_short_hash()
if data.onchainwallet: if data.onchainwallet:
wallet = await get_watch_wallet(data.onchainwallet)
onchain = await get_fresh_address(data.onchainwallet) onchain = await get_fresh_address(data.onchainwallet)
onchainaddress = onchain.address onchainaddress = onchain.address
else: else:
@@ -89,7 +88,8 @@ async def get_charge(charge_id: str) -> Charges:
async def get_charges(user: str) -> List[Charges]: async def get_charges(user: str) -> List[Charges]:
rows = await db.fetchall( rows = await db.fetchall(
"""SELECT * FROM satspay.charges WHERE "user" = ?""", (user,) """SELECT * FROM satspay.charges WHERE "user" = ? ORDER BY "timestamp" DESC """,
(user,),
) )
return [Charges.from_row(row) for row in rows] return [Charges.from_row(row) for row in rows]
@@ -102,14 +102,16 @@ async def check_address_balance(charge_id: str) -> List[Charges]:
charge = await get_charge(charge_id) charge = await get_charge(charge_id)
if not charge.paid: if not charge.paid:
if charge.onchainaddress: if charge.onchainaddress:
mempool = await get_mempool(charge.user) config = await get_config(charge.user)
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.get( r = await client.get(
mempool.endpoint + "/api/address/" + charge.onchainaddress config.mempool_endpoint
+ "/api/address/"
+ charge.onchainaddress
) )
respAmount = r.json()["chain_stats"]["funded_txo_sum"] respAmount = r.json()["chain_stats"]["funded_txo_sum"]
if respAmount >= charge.balance: if respAmount > charge.balance:
await update_charge(charge_id=charge_id, balance=respAmount) await update_charge(charge_id=charge_id, balance=respAmount)
except Exception: except Exception:
pass pass

View File

@@ -1,4 +1,4 @@
import time from datetime import datetime, timedelta
from sqlite3 import Row from sqlite3 import Row
from typing import Optional from typing import Optional
@@ -38,12 +38,16 @@ class Charges(BaseModel):
def from_row(cls, row: Row) -> "Charges": def from_row(cls, row: Row) -> "Charges":
return cls(**dict(row)) return cls(**dict(row))
@property
def time_left(self):
now = datetime.utcnow().timestamp()
start = datetime.fromtimestamp(self.timestamp)
expiration = (start + timedelta(minutes=self.time)).timestamp()
return (expiration - now) / 60
@property @property
def time_elapsed(self): def time_elapsed(self):
if (self.timestamp + (self.time * 60)) >= time.time(): return self.time_left < 0
return False
else:
return True
@property @property
def paid(self): def paid(self):

View File

@@ -0,0 +1,31 @@
const sleep = ms => new Promise(r => setTimeout(r, ms))
const retryWithDelay = async function (fn, retryCount = 0) {
try {
await sleep(25)
// Do not return the call directly, use result.
// Otherwise the error will not be cought in this try-catch block.
const result = await fn()
return result
} catch (err) {
if (retryCount > 100) throw err
await sleep((retryCount + 1) * 1000)
return retryWithDelay(fn, retryCount + 1)
}
}
const mapCharge = (obj, oldObj = {}) => {
const charge = _.clone(obj)
charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time
charge.time = minutesToTime(obj.time)
charge.timeLeft = minutesToTime(obj.time_left)
charge.expanded = false
charge.displayUrl = ['/satspay/', obj.id].join('')
charge.expanded = oldObj.expanded
charge.pendingBalance = oldObj.pendingBalance || 0
return charge
}
const minutesToTime = min =>
min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : ''

View File

@@ -8,172 +8,10 @@
Created by, <a href="https://github.com/benarc">Ben Arc</a></small Created by, <a href="https://github.com/benarc">Ben Arc</a></small
> >
</p> </p>
<br />
<br />
<a target="_blank" href="/docs#/satspay" class="text-white"
>Swagger REST API Documentation</a
>
</q-card-section> </q-card-section>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/satspay"></q-btn>
<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": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;charge_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}satspay/api/v1/charge -d
'{"onchainwallet": &lt;string, watchonly_wallet_id&gt;,
"description": &lt;string&gt;, "webhook":&lt;string&gt;, "time":
&lt;integer&gt;, "amount": &lt;integer&gt;, "lnbitswallet":
&lt;string, lnbits_wallet_id&gt;}' -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/&lt;charge_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;charge_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}satspay/api/v1/charge/&lt;charge_id&gt; -d '{"onchainwallet":
&lt;string, watchonly_wallet_id&gt;, "description": &lt;string&gt;,
"webhook":&lt;string&gt;, "time": &lt;integer&gt;, "amount":
&lt;integer&gt;, "lnbitswallet": &lt;string, lnbits_wallet_id&gt;}'
-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/&lt;charge_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;charge_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}satspay/api/v1/charge/&lt;charge_id&gt; -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": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;charge_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}satspay/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"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/satspay/api/v1/charge/&lt;charge_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.base_url
}}satspay/api/v1/charge/&lt;charge_id&gt; -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"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/satspay/api/v1/charges/balance/&lt;charge_id&gt;</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>[&lt;charge_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}satspay/api/v1/charges/balance/&lt;charge_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
</q-card> </q-card>

View File

@@ -1,223 +1,299 @@
{% extends "public.html" %} {% block page %} {% extends "public.html" %} {% block page %}
<div class="q-pa-sm theCard"> <div class="row items-center q-mt-md">
<q-card class="my-card"> <div class="col-lg-4 col-md-3 col-sm-1"></div>
<div class="column"> <div class="col-lg-4 col-md-6 col-sm-10">
<center> <q-card>
<div class="col theHeading">{{ charge.description }}</div> <div class="row q-mb-md">
</center> <div class="col text-center q-mt-md">
<div class="col"> <span class="text-h4" v-text="charge.description"></span>
<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> </div>
<div class="col" style="margin: 2px 15px; max-height: 100px"> <div class="row">
<center> <div class="col text-center">
<q-btn flat dense outline @click="copyText('{{ charge.id }}')" <div
>Charge ID: {{ charge.id }}</q-btn color="white"
style="background-color: grey; height: 30px; padding: 5px"
v-if="!charge.timeLeft"
> >
</center> Time elapsed
<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 lightning 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>
<div class="col"> <div
<q-btn color="white"
flat style="background-color: grey; height: 30px; padding: 5px"
disable v-else-if="charge.paid"
v-if="'{{ charge.onchainwallet }}' == 'None' || charge_time_elapsed == 'True'" >
style="color: primary; width: 100%" Charge paid
label="onchain⛓"
>
<q-tooltip>
bitcoin onchain 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>
<div v-else> <div v-else>
<center> <q-linear-progress
<span class="text-subtitle2" size="30px"
>Pay this <br /> :value="charge.progress"
lightning-network invoice</span color="secondary"
>
<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 %} {{ charge.timeLeft }} {% endraw %}</span
></span
>
</q-item>
</q-item-section>
</q-linear-progress>
</div>
</div>
</div>
<div class="row q-ml-md q-mt-md q-mb-lg">
<div class="col">
<div class="row">
<div class="col-4 q-pr-lg">Charge Id:</div>
<div class="col-8 q-pr-lg">
<q-btn flat dense outline @click="copyText(charge.id)"
><span v-text="charge.id"></span
></q-btn>
</div>
</div>
<div class="row items-center">
<div class="col-4 q-pr-lg">Total to pay:</div>
<div class="col-8 q-pr-lg">
<q-badge color="blue">
<span v-text="charge.amount" class="text-subtitle2"></span> sat
</q-badge>
</div>
</div>
<div class="row items-center q-mt-sm">
<div class="col-4 q-pr-lg">Amount paid:</div>
<div class="col-8 q-pr-lg">
<q-badge color="orange">
<span v-text="charge.balance" class="text-subtitle2"></span>
sat</q-badge
> >
</center> </div>
<a href="lightning:{{ charge.payment_request }}"> </div>
<q-responsive :ratio="1" class="q-mx-md"> <div v-if="pendingFunds" class="row items-center q-mt-sm">
<qrcode <div class="col-4 q-pr-lg">Amount pending:</div>
:value="'{{ charge.payment_request }}'" <div class="col-8 q-pr-lg">
:options="{width: 800}" <q-badge color="gray">
class="rounded-borders" <span v-text="pendingFunds" class="text-subtitle2"></span> sat
></qrcode> </q-badge>
</q-responsive> </div>
</a> </div>
<div class="row q-mt-lg"> <div class="row items-center q-mt-sm">
<q-btn <div class="col-4 q-pr-lg">Amount due:</div>
outline <div class="col-8 q-pr-lg">
color="grey" <q-badge v-if="charge.amount - charge.balance > 0" color="green">
@click="copyText('{{ charge.payment_request }}')" <span
>Copy invoice</q-btn v-text="charge.amount - charge.balance"
class="text-subtitle2"
></span>
sat
</q-badge>
<q-badge
v-else="charge.amount - charge.balance <= 0"
color="green"
class="text-subtitle2"
>
none</q-badge
> >
</div> </div>
</div> </div>
</div> </div>
</div>
<q-separator></q-separator>
<div class="row">
<div class="col">
<div class="row">
<div class="col">
<q-btn
flat
disable
v-if="!charge.lnbitswallet || charge.time_elapsed"
style="color: primary; width: 100%"
label="lightning⚡"
>
<q-tooltip>
bitcoin lightning payment method not available
</q-tooltip>
</q-btn>
<q-btn
flat
v-else
@click="payInvoice"
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 || charge.time_elapsed"
style="color: primary; width: 100%"
label="onchain⛓"
>
<q-tooltip>
bitcoin onchain payment method not available
</q-tooltip>
</q-btn>
<q-btn
flat
v-else
@click="payOnchain"
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>
<q-card class="q-pa-lg" v-if="lnbtc">
<q-card-section class="q-pa-none">
<div class="row items-center q-mt-sm">
<div class="col-md-2 col-sm-0"></div>
<div class="col-md-8 col-sm-12">
<div v-if="!charge.timeLeft && !charge.paid">
<q-icon
name="block"
style="color: #ccc; font-size: 21.4em"
></q-icon>
</div>
<div v-else-if="charge.paid">
<q-icon
name="check"
style="color: green; font-size: 21.4em"
></q-icon>
<q-btn
outline
v-if="charge.webhook"
type="a"
:href="charge.completelink"
:label="charge.completelinktext"
></q-btn>
</div>
<div v-else>
<div class="row text-center q-mb-sm">
<div class="col text-center">
<span class="text-subtitle2"
>Pay this lightning-network invoice:</span
>
</div>
</div>
<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 text-center q-mt-lg">
<div class="col text-center">
<q-btn
outline
color="grey"
@click="copyText(charge.payment_request)"
>Copy invoice</q-btn
>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-2 col-sm-0"></div>
</q-card-section> </q-card-section>
</q-card> </q-card>
<q-card class="q-pa-lg" v-if="onbtc"> <q-card class="q-pa-lg" v-if="onbtc">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<div class="text-center q-pt-md"> <div v-if="charge.timeLeft && !charge.paid" class="row items-center">
<div v-if="timetoComplete < 1 && charge_paid == 'False'"> <div class="col text-center">
<q-icon <a
name="block" style="color: unset"
style="color: #ccc; font-size: 21.4em" :href="mempool_endpoint + '/address/' + charge.onchainaddress"
></q-icon> target="_blank"
</div> ><span
<div v-else-if="charge_paid == 'True'"> class="text-subtitle1"
<q-icon v-text="charge.onchainaddress"
name="check" ></span>
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> </a>
<div class="row q-mt-lg"> </div>
</div>
<div class="row items-center q-mt-md">
<div class="col-md-2 col-sm-0"></div>
<div class="col-md-8 col-sm-12 text-center">
<div v-if="!charge.timeLeft && !charge.paid">
<q-icon
name="block"
style="color: #ccc; font-size: 21.4em"
></q-icon>
</div>
<div v-else-if="charge.paid">
<q-icon
name="check"
style="color: green; font-size: 21.4em"
></q-icon>
<q-btn <q-btn
outline outline
color="grey" v-if="charge.webhook"
@click="copyText('{{ charge.onchainaddress }}')" type="a"
>Copy address</q-btn :href="charge.completelink"
> :label="charge.completelinktext"
></q-btn>
</div>
<div v-else>
<div class="row items-center q-mb-sm">
<div class="col text-center">
<span class="text-subtitle2"
>Send
<span v-text="charge.amount"></span>
sats to this onchain address</span
>
</div>
</div>
<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 items-center q-mt-lg">
<div class="col text-center">
<q-btn
outline
color="grey"
@click="copyText(charge.onchainaddress)"
>Copy address</q-btn
>
</div>
</div>
</div> </div>
</div> </div>
<div class="col-md-2 col-sm-0"></div>
</div> </div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-card> </div>
<div class="col-lg- 4 col-md-3 col-sm-1"></div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<style> <script src="https://mempool.space/mempool.js"></script>
.theCard { <script src="{{ url_for('satspay_static', path='js/utils.js') }}"></script>
width: 360px;
margin: 10px auto;
}
.theHeading {
margin: 15px;
font-size: 25px;
}
</style>
<script> <script>
Vue.component(VueQrcode.name, VueQrcode) Vue.component(VueQrcode.name, VueQrcode)
@@ -226,16 +302,14 @@
mixins: [windowMixin], mixins: [windowMixin],
data() { data() {
return { return {
charge: JSON.parse('{{charge_data | tojson}}'),
mempool_endpoint: '{{mempool_endpoint}}',
pendingFunds: 0,
ws: null,
newProgress: 0.4, newProgress: 0.4,
counter: 1, counter: 1,
newTimeLeft: '',
timetoComplete: 100,
lnbtc: true, lnbtc: true,
onbtc: false, onbtc: false,
charge_time_elapsed: '{{charge.time_elapsed}}',
charge_amount: '{{charge.amount}}',
charge_balance: '{{charge.balance}}',
charge_paid: '{{charge.paid}}',
wallet: { wallet: {
inkey: '' inkey: ''
}, },
@@ -245,90 +319,141 @@
methods: { methods: {
startPaymentNotifier() { startPaymentNotifier() {
this.cancelListener() this.cancelListener()
if (!this.lnbitswallet) return
this.cancelListener = LNbits.event.onInvoicePaid( this.cancelListener = LNbits.events.onInvoicePaid(
this.wallet, this.wallet,
payment => { payment => {
this.checkBalance() this.checkInvoiceBalance()
} }
) )
}, },
checkBalance: function () { checkBalances: async function () {
var self = this if (!this.charge.hasStaleBalance) await this.refreshCharge()
LNbits.api try {
.request( const {data} = await LNbits.api.request(
'GET', 'GET',
'/satspay/api/v1/charges/balance/{{ charge.id }}', `/satspay/api/v1/charge/balance/${this.charge.id}`
'filla'
) )
.then(function (response) { this.charge = mapCharge(data, this.charge)
self.charge_time_elapsed = response.data.time_elapsed } catch (error) {
self.charge_amount = response.data.amount LNbits.utils.notifyApiError(error)
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 () { refreshCharge: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
`/satspay/api/v1/charge/${this.charge.id}`
)
this.charge = mapCharge(data, this.charge)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
checkPendingOnchain: async function () {
const {
bitcoin: {addresses: addressesAPI}
} = mempoolJS({
hostname: new URL(this.mempool_endpoint).hostname
})
try {
const utxos = await addressesAPI.getAddressTxsUtxo({
address: this.charge.onchainaddress
})
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
this.charge.hasStaleBalance = this.charge.balance === newBalance
this.pendingFunds = utxos
.filter(u => !u.status.confirmed)
.reduce((t, u) => t + u.value, 0)
} catch (error) {
console.error('cannot check pending funds')
}
},
payInvoice: function () {
this.lnbtc = true this.lnbtc = true
this.onbtc = false this.onbtc = false
}, },
payON: function () { payOnchain: function () {
this.lnbtc = false this.lnbtc = false
this.onbtc = true 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 () { loopRefresh: function () {
self = this // invoice only
var refreshIntervalId = setInterval(function () { const refreshIntervalId = setInterval(async () => {
if (self.charge_paid == 'True' || self.timetoComplete < 1) { if (this.charge.paid || !this.charge.timeLeft) {
clearInterval(refreshIntervalId) clearInterval(refreshIntervalId)
} }
self.getTheTime() if (this.counter % 10 === 0) {
self.getThePercentage() await this.checkBalances()
self.counter++ await this.checkPendingOnchain()
if (self.counter % 10 === 0) {
self.checkBalance()
} }
this.counter++
}, 1000) }, 1000)
},
initWs: async function () {
const {
bitcoin: {websocket}
} = mempoolJS({
hostname: new URL(this.mempool_endpoint).hostname
})
this.ws = new WebSocket('wss://mempool.space/api/v1/ws')
this.ws.addEventListener('open', x => {
if (this.charge.onchainaddress) {
this.trackAddress(this.charge.onchainaddress)
}
})
this.ws.addEventListener('message', async ({data}) => {
const res = JSON.parse(data.toString())
if (res['address-transactions']) {
await this.checkBalances()
this.$q.notify({
type: 'positive',
message: 'New payment received!',
timeout: 10000
})
}
})
},
loopPingWs: function () {
setInterval(() => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
this.ws.send(JSON.stringify({action: 'ping'}))
}, 30 * 1000)
},
trackAddress: async function (address, retry = 0) {
try {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
this.ws.send(JSON.stringify({'track-address': address}))
} catch (error) {
await sleep(1000)
if (retry > 10) throw error
this.trackAddress(address, retry + 1)
}
} }
}, },
created: function () { created: async function () {
console.log('{{ charge.onchainaddress }}' == 'None') if (this.charge.lnbitswallet) this.payInvoice()
if ('{{ charge.lnbitswallet }}' == 'None') { else this.payOnchain()
this.lnbtc = false await this.checkBalances()
this.onbtc = true
} // empty for onchain
this.wallet.inkey = '{{ wallet_inkey }}' this.wallet.inkey = '{{ wallet_inkey }}'
this.getTheTime()
this.getThePercentage()
var timerCount = this.timerCount
if ('{{ charge.paid }}' == 'False') {
timerCount()
}
this.startPaymentNotifier() this.startPaymentNotifier()
if (!this.charge.paid) {
this.loopRefresh()
}
if (this.charge.onchainaddress) {
this.loopPingWs()
this.checkPendingOnchain()
this.trackAddress(this.charge.onchainaddress)
}
} }
}) })
</script> </script>

View File

@@ -18,46 +18,54 @@
<h5 class="text-subtitle1 q-my-none">Charges</h5> <h5 class="text-subtitle1 q-my-none">Charges</h5>
</div> </div>
<div class="col-auto"> <div class="col q-pr-lg">
<q-input <q-input
borderless borderless
dense dense
debounce="300" debounce="300"
v-model="filter" v-model="filter"
placeholder="Search" placeholder="Search"
class="float-right"
> >
<template v-slot:append> <template v-slot:append>
<q-icon name="search"></q-icon> <q-icon name="search"></q-icon>
</template> </template>
</q-input> </q-input>
<q-btn flat color="grey" @click="exportchargeCSV" </div>
>Export to CSV</q-btn <div class="col-auto">
> <q-btn outline color="grey" label="...">
<q-menu auto-close>
<q-list style="min-width: 100px">
<q-item clickable>
<q-item-section @click="exportchargeCSV"
>Export to CSV</q-item-section
>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div> </div>
</div> </div>
<q-table <q-table
flat flat
dense dense
:data="ChargeLinks" :data="chargeLinks"
row-key="id" row-key="id"
:columns="ChargesTable.columns" :columns="chargesTable.columns"
:pagination.sync="ChargesTable.pagination" :pagination.sync="chargesTable.pagination"
:filter="filter" :filter="filter"
> >
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th auto-width></q-th> <q-th auto-width>Status </q-th>
<q-th auto-width>Title</q-th>
<q-th <q-th auto-width>Time Left (hh:mm)</q-th>
v-for="col in props.cols" <q-th auto-width>Time To Pay (hh:mm)</q-th>
:key="col.name" <q-th auto-width>Amount To Pay</q-th>
:props="props" <q-th auto-width>Balance</q-th>
auto-width <q-th auto-width>Pending Balance</q-th>
> <q-th auto-width>Onchain Address</q-th>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.label }}</div>
</q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
</q-tr> </q-tr>
</template> </template>
@@ -66,73 +74,179 @@
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn
unelevated size="sm"
color="accent"
round
dense dense
size="xs" @click="props.row.expanded= !props.row.expanded"
icon="link" :icon="props.row.expanded? 'remove' : 'add'"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" />
type="a" </q-td>
<q-td auto-width>
<q-badge
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
color="red"
>
<a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>expired</a
>
</q-badge>
<q-badge
v-else-if="props.row.balance >= props.row.amount"
color="green"
>
<a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>paid</a
>
</q-badge>
<q-badge v-else color="blue"
><a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>waiting</a
>
</q-badge>
</q-td>
<q-td key="description" :props="props" :class="">
<a
:href="props.row.displayUrl" :href="props.row.displayUrl"
target="_blank" target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.description}}</a
> >
<q-tooltip> Payment link </q-tooltip>
</q-btn>
</q-td> </q-td>
<q-td auto-width> <q-td key="timeLeft" :props="props" :class="">
<q-btn <div>{{props.row.timeLeft}}</div>
v-if="props.row.time_elapsed && props.row.balance < props.row.amount" <q-linear-progress
unelevated v-if="props.row.timeLeft"
flat :value="props.row.progress"
dense color="secondary"
size="xs"
icon="error"
:color="($q.dark.isActive) ? 'red' : 'red'"
> >
<q-tooltip> Time elapsed </q-tooltip> </q-linear-progress>
</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>
<q-td <q-td key="time to pay" :props="props" :class="">
v-for="col in props.cols" <div>{{props.row.time}}</div>
:key="col.name" </q-td>
:props="props" <q-td key="amount" :props="props" :class="">
auto-width <div>{{props.row.amount}}</div>
> </q-td>
<div v-if="col.name == 'id'"></div> <q-td key="balance" :props="props" :class="">
<div v-else>{{ col.value }}</div> <div>{{props.row.balance}}</div>
</q-td>
<q-td key="pendingBalance" :props="props" :class="">
<div>
{{props.row.pendingBalance ? props.row.pendingBalance : ''}}
</div>
</q-td>
<q-td key="onchain address" :props="props" :class="">
<a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.onchainaddress}}</a
>
</q-td>
</q-tr>
<q-tr v-show="props.row.expanded" :props="props">
<q-td colspan="100%">
<div
v-if="props.row.onchainwallet"
class="row items-center q-mt-md q-mb-lg"
>
<div class="col-2 q-pr-lg">Onchain Wallet:</div>
<div class="col-4 q-pr-lg">
{{getOnchainWalletName(props.row.onchainwallet)}}
</div>
</div>
<div
v-if="props.row.lnbitswallet"
class="row items-center q-mt-md q-mb-lg"
>
<div class="col-2 q-pr-lg">LNbits Wallet:</div>
<div class="col-4 q-pr-lg">
{{getLNbitsWalletName(props.row.lnbitswallet)}}
</div>
</div>
<div
v-if="props.row.completelink || props.row.completelinktext"
class="row items-center q-mt-md q-mb-lg"
>
<div class="col-2 q-pr-lg">Completed Link:</div>
<div class="col-4 q-pr-lg">
<a
:href="props.row.completelink"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.completelinktext ||
props.row.completelink}}</a
>
</div>
</div>
<div
v-if="props.row.webhook"
class="row items-center q-mt-md q-mb-lg"
>
<div class="col-2 q-pr-lg">Webhook:</div>
<div class="col-4 q-pr-lg">
<a
:href="props.row.webhook"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.webhook || props.row.webhook}}</a
>
</div>
</div>
<div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg">ID:</div>
<div class="col-4 q-pr-lg">{{props.row.id}}</div>
</div>
<div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg"></div>
<div class="col-6 q-pr-lg">
<q-btn
unelevated
color="gray"
outline
type="a"
:href="props.row.displayUrl"
target="_blank"
class="float-left q-mr-lg"
>Details</q-btn
>
<q-btn
unelevated
color="gray"
outline
type="a"
@click="refreshBalance(props.row)"
target="_blank"
class="float-left"
>Refresh Balance</q-btn
>
</div>
<div class="col-4 q-pr-lg">
<q-btn
unelevated
color="pink"
icon="cancel"
@click="deleteChargeLink(props.row.id)"
>Delete</q-btn
>
</div>
<div class="col-4"></div>
<div class="col-2 q-pr-lg"></div>
</div>
</q-td> </q-td>
</q-tr> </q-tr>
</template> </template>
@@ -155,11 +269,7 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<q-dialog <q-dialog v-model="formDialogCharge.show" position="top">
v-model="formDialogCharge.show"
position="top"
@hide="closeFormDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormDataCharge" class="q-gutter-md"> <q-form @submit="sendFormDataCharge" class="q-gutter-md">
<q-input <q-input
@@ -246,7 +356,7 @@
filled filled
dense dense
emit-value emit-value
v-model="formDialogCharge.data.onchainwallet" v-model="onchainwallet"
:options="walletLinks" :options="walletLinks"
label="Onchain Wallet" label="Onchain Wallet"
/> />
@@ -284,49 +394,28 @@
<!-- lnbits/static/vendor <!-- lnbits/static/vendor
<script src="/vendor/vue-qrcode@1.0.2/vue-qrcode.min.js"></script> --> <script src="/vendor/vue-qrcode@1.0.2/vue-qrcode.min.js"></script> -->
<style></style> <style></style>
<!-- todo: use config mempool -->
<script src="https://mempool.space/mempool.js"></script>
<script src="{{ url_for('satspay_static', path='js/utils.js') }}"></script>
<script> <script>
Vue.component(VueQrcode.name, VueQrcode) 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({ new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
return { return {
filter: '', filter: '',
watchonlyactive: false,
balance: null, balance: null,
checker: null,
walletLinks: [], walletLinks: [],
ChargeLinks: [], chargeLinks: [],
ChargeLinksObj: [],
onchainwallet: '', onchainwallet: '',
currentaddress: '', rescanning: false,
Addresses: {
show: false,
data: null
},
mempool: { mempool: {
endpoint: '' endpoint: ''
}, },
ChargesTable: { chargesTable: {
columns: [ columns: [
{ {
name: 'theId', name: 'theId',
@@ -341,10 +430,10 @@
field: 'description' field: 'description'
}, },
{ {
name: 'timeleft', name: 'timeLeft',
align: 'left', align: 'left',
label: 'Time left', label: 'Time left',
field: 'date' field: 'timeLeft'
}, },
{ {
name: 'time to pay', name: 'time to pay',
@@ -364,6 +453,12 @@
label: 'Balance', label: 'Balance',
field: 'balance' field: 'balance'
}, },
{
name: 'pendingBalance',
align: 'left',
label: 'Pending Balance',
field: 'pendingBalance'
},
{ {
name: 'onchain address', name: 'onchain address',
align: 'left', align: 'left',
@@ -393,172 +488,218 @@
rowsPerPage: 10 rowsPerPage: 10
} }
}, },
formDialog: {
show: false,
data: {}
},
formDialogCharge: { formDialogCharge: {
show: false, show: false,
data: { data: {
onchain: false,
onchainwallet: '',
lnbits: false,
description: '',
time: null,
amount: null
}
}
}
},
methods: {
cancelCharge: function (data) {
this.formDialogCharge.data.description = ''
this.formDialogCharge.data.onchainwallet = ''
this.formDialogCharge.data.lnbitswallet = ''
this.formDialogCharge.data.time = null
this.formDialogCharge.data.amount = null
this.formDialogCharge.data.webhook = ''
this.formDialogCharge.data.completelink = ''
this.formDialogCharge.show = false
},
getWalletLinks: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/wallet',
this.g.user.wallets[0].inkey
)
this.walletLinks = data.map(w => ({
id: w.id,
label: w.title + ' - ' + w.id
}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getWalletConfig: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/config',
this.g.user.wallets[0].inkey
)
this.mempool.endpoint = data.mempool_endpoint
const url = new URL(this.mempool.endpoint)
this.mempool.hostname = url.hostname
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getOnchainWalletName: function (walletId) {
const wallet = this.walletLinks.find(w => w.id === walletId)
if (!wallet) return 'unknown'
return wallet.label
},
getLNbitsWalletName: function (walletId) {
const wallet = this.g.user.walletOptions.find(w => w.value === walletId)
if (!wallet) return 'unknown'
return wallet.label
},
getCharges: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/satspay/api/v1/charges',
this.g.user.wallets[0].inkey
)
this.chargeLinks = data.map(c =>
mapCharge(
c,
this.chargeLinks.find(old => old.id === c.id)
)
)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendFormDataCharge: function () {
const wallet = this.g.user.wallets[0].inkey
const data = this.formDialogCharge.data
data.amount = parseInt(data.amount)
data.time = parseInt(data.time)
data.onchainwallet = this.onchainwallet?.id
this.createCharge(wallet, data)
},
refreshActiveChargesBalance: async function () {
try {
const activeLinkIds = this.chargeLinks
.filter(c => !c.paid && !c.time_elapsed && !c.hasStaleBalance)
.map(c => c.id)
.join(',')
if (activeLinkIds) {
await LNbits.api.request(
'GET',
'/satspay/api/v1/charges/balance/' + activeLinkIds,
'filla'
)
}
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
await this.getCharges()
}
},
refreshBalance: async function (charge) {
try {
const {data} = await LNbits.api.request(
'GET',
'/satspay/api/v1/charge/balance/' + charge.id,
'filla'
)
charge.balance = data.balance
} catch (error) {}
},
rescanOnchainAddresses: async function () {
if (this.rescanning) return
this.rescanning = true
const {
bitcoin: {addresses: addressesAPI}
} = mempoolJS({hostname: this.mempool.hostname})
try {
const onchainActiveCharges = this.chargeLinks.filter(
c => c.onchainaddress && !c.paid && !c.time_elapsed
)
for (const charge of onchainActiveCharges) {
const fn = async () =>
addressesAPI.getAddressTxsUtxo({
address: charge.onchainaddress
})
const utxos = await retryWithDelay(fn)
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
charge.pendingBalance = utxos
.filter(u => !u.status.confirmed)
.reduce((t, u) => t + u.value, 0)
charge.hasStaleBalance = charge.balance === newBalance
}
} catch (error) {
console.error(error)
} finally {
this.rescanning = false
}
},
createCharge: async function (wallet, data) {
try {
const resp = await LNbits.api.request(
'POST',
'/satspay/api/v1/charge',
wallet,
data
)
this.chargeLinks.unshift(mapCharge(resp.data))
this.formDialogCharge.show = false
this.formDialogCharge.data = {
onchain: false, onchain: false,
lnbits: false, lnbits: false,
description: '', description: '',
time: null, time: null,
amount: null amount: null
} }
}, } catch (error) {
qrCodeDialog: { LNbits.utils.notifyApiError(error)
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].inkey
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
self.formDialogCharge.data = {
onchain: false,
lnbits: false,
description: '',
time: null,
amount: null
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}, },
deleteChargeLink: function (chargeId) { deleteChargeLink: function (chargeId) {
var self = this const link = _.findWhere(this.chargeLinks, {id: chargeId})
var link = _.findWhere(this.ChargeLinks, {id: chargeId})
LNbits.utils LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?') .confirmDialog('Are you sure you want to delete this pay link?')
.onOk(function () { .onOk(async () => {
LNbits.api try {
.request( const response = await LNbits.api.request(
'DELETE', 'DELETE',
'/satspay/api/v1/charge/' + chargeId, '/satspay/api/v1/charge/' + chargeId,
self.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
.then(function (response) {
self.ChargeLinks = _.reject(self.ChargeLinks, function (obj) { this.chargeLinks = _.reject(this.chargeLinks, function (obj) {
return obj.id === chargeId return obj.id === chargeId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
}) })
} catch (error) {
LNbits.utils.notifyApiError(error)
}
}) })
}, },
exportchargeCSV: function () { exportchargeCSV: function () {
var self = this LNbits.utils.exportCSV(
LNbits.utils.exportCSV(self.ChargesTable.columns, this.ChargeLinks) this.chargesTable.columns,
this.chargeLinks,
'charges'
)
} }
}, },
created: function () { created: async function () {
console.log(this.g.user) await this.getCharges()
var self = this await this.getWalletLinks()
var getCharges = this.getCharges await this.getWalletConfig()
getCharges() setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000)
var getWalletLinks = this.getWalletLinks await this.rescanOnchainAddresses()
getWalletLinks() setInterval(() => this.rescanOnchainAddresses(), 10 * 1000)
var timerCount = this.timerCount
timerCount()
} }
}) })
</script> </script>

View File

@@ -9,6 +9,7 @@ from starlette.responses import HTMLResponse
from lnbits.core.crud import get_wallet from lnbits.core.crud import get_wallet
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.decorators import check_user_exists from lnbits.decorators import check_user_exists
from lnbits.extensions.watchonly.crud import get_config
from . import satspay_ext, satspay_renderer from . import satspay_ext, satspay_renderer
from .crud import get_charge from .crud import get_charge
@@ -24,14 +25,21 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
@satspay_ext.get("/{charge_id}", response_class=HTMLResponse) @satspay_ext.get("/{charge_id}", response_class=HTMLResponse)
async def display(request: Request, charge_id): async def display(request: Request, charge_id: str):
charge = await get_charge(charge_id) charge = await get_charge(charge_id)
if not charge: if not charge:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist."
) )
wallet = await get_wallet(charge.lnbitswallet) wallet = await get_wallet(charge.lnbitswallet)
onchainwallet_config = await get_config(charge.user)
inkey = wallet.inkey if wallet else None
return satspay_renderer().TemplateResponse( return satspay_renderer().TemplateResponse(
"satspay/display.html", "satspay/display.html",
{"request": request, "charge": charge, "wallet_key": wallet.inkey}, {
"request": request,
"charge_data": charge.dict(),
"wallet_inkey": inkey,
"mempool_endpoint": onchainwallet_config.mempool_endpoint,
},
) )

View File

@@ -1,7 +1,6 @@
from http import HTTPStatus from http import HTTPStatus
import httpx import httpx
from fastapi import Query
from fastapi.params import Depends from fastapi.params import Depends
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
@@ -31,7 +30,12 @@ async def api_charge_create(
data: CreateCharge, wallet: WalletTypeInfo = Depends(require_invoice_key) data: CreateCharge, wallet: WalletTypeInfo = Depends(require_invoice_key)
): ):
charge = await create_charge(user=wallet.wallet.user, data=data) charge = await create_charge(user=wallet.wallet.user, data=data)
return charge.dict() return {
**charge.dict(),
**{"time_elapsed": charge.time_elapsed},
**{"time_left": charge.time_left},
**{"paid": charge.paid},
}
@satspay_ext.put("/api/v1/charge/{charge_id}") @satspay_ext.put("/api/v1/charge/{charge_id}")
@@ -51,6 +55,7 @@ async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
{ {
**charge.dict(), **charge.dict(),
**{"time_elapsed": charge.time_elapsed}, **{"time_elapsed": charge.time_elapsed},
**{"time_left": charge.time_left},
**{"paid": charge.paid}, **{"paid": charge.paid},
} }
for charge in await get_charges(wallet.wallet.user) for charge in await get_charges(wallet.wallet.user)
@@ -73,6 +78,7 @@ async def api_charge_retrieve(
return { return {
**charge.dict(), **charge.dict(),
**{"time_elapsed": charge.time_elapsed}, **{"time_elapsed": charge.time_elapsed},
**{"time_left": charge.time_left},
**{"paid": charge.paid}, **{"paid": charge.paid},
} }
@@ -93,9 +99,18 @@ async def api_charge_delete(charge_id, wallet: WalletTypeInfo = Depends(get_key_
#############################BALANCE########################## #############################BALANCE##########################
@satspay_ext.get("/api/v1/charges/balance/{charge_id}") @satspay_ext.get("/api/v1/charges/balance/{charge_ids}")
async def api_charges_balance(charge_id): async def api_charges_balance(charge_ids):
charge_id_list = charge_ids.split(",")
charges = []
for charge_id in charge_id_list:
charge = await api_charge_balance(charge_id)
charges.append(charge)
return charges
@satspay_ext.get("/api/v1/charge/balance/{charge_id}")
async def api_charge_balance(charge_id):
charge = await check_address_balance(charge_id) charge = await check_address_balance(charge_id)
if not charge: if not charge:
@@ -125,23 +140,9 @@ async def api_charges_balance(charge_id):
) )
except AssertionError: except AssertionError:
charge.webhook = None charge.webhook = None
return charge.dict() return {
**charge.dict(),
**{"time_elapsed": charge.time_elapsed},
#############################MEMPOOL########################## **{"time_left": charge.time_left},
**{"paid": charge.paid},
}
@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()

View File

@@ -238,41 +238,3 @@ async def get_config(user: str) -> Optional[Config]:
"""SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,) """SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,)
) )
return json.loads(row[0], object_hook=lambda d: Config(**d)) if row else None return json.loads(row[0], object_hook=lambda d: Config(**d)) if row else None
######################MEMPOOL#######################
### TODO: fix statspay dependcy and remove
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
### TODO: fix statspay dependcy and remove
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
### TODO: fix statspay dependcy and remove
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

View File

@@ -647,7 +647,9 @@ new Vue({
getAddressTxsDelayed: async function (addrData) { getAddressTxsDelayed: async function (addrData) {
const { const {
bitcoin: {addresses: addressesAPI} bitcoin: {addresses: addressesAPI}
} = mempoolJS() } = mempoolJS({
hostname: new URL(this.config.data.mempool_endpoint).hostname
})
const fn = async () => const fn = async () =>
addressesAPI.getAddressTxs({ addressesAPI.getAddressTxs({
@@ -660,7 +662,9 @@ new Vue({
refreshRecommendedFees: async function () { refreshRecommendedFees: async function () {
const { const {
bitcoin: {fees: feesAPI} bitcoin: {fees: feesAPI}
} = mempoolJS() } = mempoolJS({
hostname: new URL(this.config.data.mempool_endpoint).hostname
})
const fn = async () => feesAPI.getFeesRecommended() const fn = async () => feesAPI.getFeesRecommended()
this.payment.recommededFees = await retryWithDelay(fn) this.payment.recommededFees = await retryWithDelay(fn)
@@ -668,7 +672,9 @@ new Vue({
getAddressTxsUtxoDelayed: async function (address) { getAddressTxsUtxoDelayed: async function (address) {
const { const {
bitcoin: {addresses: addressesAPI} bitcoin: {addresses: addressesAPI}
} = mempoolJS() } = mempoolJS({
hostname: new URL(this.config.data.mempool_endpoint).hostname
})
const fn = async () => const fn = async () =>
addressesAPI.getAddressTxsUtxo({ addressesAPI.getAddressTxsUtxo({
@@ -679,7 +685,9 @@ new Vue({
fetchTxHex: async function (txId) { fetchTxHex: async function (txId) {
const { const {
bitcoin: {transactions: transactionsAPI} bitcoin: {transactions: transactionsAPI}
} = mempoolJS() } = mempoolJS({
hostname: new URL(this.config.data.mempool_endpoint).hostname
})
try { try {
const response = await transactionsAPI.getTxHex({txid: txId}) const response = await transactionsAPI.getTxHex({txid: txId})

View File

@@ -1198,6 +1198,7 @@
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<!-- todo: use endpoint here -->
<script type="text/javascript" src="https://mempool.space/mempool.js"></script> <script type="text/javascript" src="https://mempool.space/mempool.js"></script>
<script src="{{ url_for('watchonly_static', path='js/tables.js') }}"></script> <script src="{{ url_for('watchonly_static', path='js/tables.js') }}"></script>

View File

@@ -15,19 +15,16 @@ from lnbits.extensions.watchonly import watchonly_ext
from .crud import ( from .crud import (
create_config, create_config,
create_fresh_addresses, create_fresh_addresses,
create_mempool,
create_watch_wallet, create_watch_wallet,
delete_addresses_for_wallet, delete_addresses_for_wallet,
delete_watch_wallet, delete_watch_wallet,
get_addresses, get_addresses,
get_config, get_config,
get_fresh_address, get_fresh_address,
get_mempool,
get_watch_wallet, get_watch_wallet,
get_watch_wallets, get_watch_wallets,
update_address, update_address,
update_config, update_config,
update_mempool,
update_watch_wallet, update_watch_wallet,
) )
from .helpers import parse_key from .helpers import parse_key
@@ -281,23 +278,3 @@ async def api_get_config(w: WalletTypeInfo = Depends(get_key_type)):
if not config: if not config:
config = await create_config(user=w.wallet.user) config = await create_config(user=w.wallet.user)
return config.dict() return config.dict()
#############################MEMPOOL##########################
### TODO: fix statspay dependcy and remove
@watchonly_ext.put("/api/v1/mempool")
async def api_update_mempool(
endpoint: str = Query(...), w: WalletTypeInfo = Depends(require_admin_key)
):
mempool = await update_mempool(**{"endpoint": endpoint}, user=w.wallet.user)
return mempool.dict()
### TODO: fix statspay dependcy and remove
@watchonly_ext.get("/api/v1/mempool")
async def api_get_mempool(w: WalletTypeInfo = Depends(require_admin_key)):
mempool = await get_mempool(w.wallet.user)
if not mempool:
mempool = await create_mempool(user=w.wallet.user)
return mempool.dict()