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>
</q-card-section> <br />
<q-expansion-item <br />
group="extras" <a target="_blank" href="/docs#/satspay" class="text-white"
icon="swap_vertical_circle" >Swagger REST API Documentation</a
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-section>
</q-card> </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>

View File

@@ -1,36 +1,42 @@
{% 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>
</div>
<div class="row">
<div class="col text-center">
<div <div
class="col"
color="white" color="white"
style="background-color: grey; height: 30px; padding: 5px" style="background-color: grey; height: 30px; padding: 5px"
v-if="timetoComplete < 1" v-if="!charge.timeLeft"
> >
<center>Time elapsed</center> Time elapsed
</div> </div>
<div <div
class="col"
color="white" color="white"
style="background-color: grey; height: 30px; padding: 5px" style="background-color: grey; height: 30px; padding: 5px"
v-else-if="charge_paid == 'True'" v-else-if="charge.paid"
> >
<center>Charge paid</center> Charge paid
</div> </div>
<div v-else> <div v-else>
<q-linear-progress size="30px" :value="newProgress" color="grey"> <q-linear-progress
size="30px"
:value="charge.progress"
color="secondary"
>
<q-item-section> <q-item-section>
<q-item style="padding: 3px"> <q-item style="padding: 3px">
<q-spinner color="white" size="0.8em"></q-spinner <q-spinner color="white" size="0.8em"></q-spinner
><span style="font-size: 15px; color: white" ><span style="font-size: 15px; color: white"
><span class="q-pr-xl q-pl-md"> Awaiting payment...</span> ><span class="q-pr-xl q-pl-md"> Awaiting payment...</span>
<span class="q-pl-xl" style="color: white"> <span class="q-pl-xl" style="color: white">
{% raw %} {{ newTimeLeft }} {% endraw %}</span {% raw %} {{ charge.timeLeft }} {% endraw %}</span
></span ></span
> >
</q-item> </q-item>
@@ -38,28 +44,72 @@
</q-linear-progress> </q-linear-progress>
</div> </div>
</div> </div>
<div class="col" style="margin: 2px 15px; max-height: 100px"> </div>
<center> <div class="row q-ml-md q-mt-md q-mb-lg">
<q-btn flat dense outline @click="copyText('{{ charge.id }}')" <div class="col">
>Charge ID: {{ charge.id }}</q-btn <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>
</div>
<div v-if="pendingFunds" class="row items-center q-mt-sm">
<div class="col-4 q-pr-lg">Amount pending:</div>
<div class="col-8 q-pr-lg">
<q-badge color="gray">
<span v-text="pendingFunds" 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 due:</div>
<div class="col-8 q-pr-lg">
<q-badge v-if="charge.amount - charge.balance > 0" color="green">
<span <span
><small v-text="charge.amount - charge.balance"
>{% raw %} Total to pay: {{ charge_amount }}sats<br /> class="text-subtitle2"
Amount paid: {{ charge_balance }}</small ></span>
><br /> sat
Amount due: {{ charge_amount - charge_balance }}sats {% endraw %} </q-badge>
</span> <q-badge
v-else="charge.amount - charge.balance <= 0"
color="green"
class="text-subtitle2"
>
none</q-badge
>
</div>
</div>
</div>
</div> </div>
<q-separator></q-separator> <q-separator></q-separator>
<div class="row">
<div class="col"> <div class="col">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<q-btn <q-btn
flat flat
disable disable
v-if="'{{ charge.lnbitswallet }}' == 'None' || charge_time_elapsed == 'True'" v-if="!charge.lnbitswallet || charge.time_elapsed"
style="color: primary; width: 100%" style="color: primary; width: 100%"
label="lightning⚡" label="lightning⚡"
> >
@@ -70,7 +120,7 @@
<q-btn <q-btn
flat flat
v-else v-else
@click="payLN" @click="payInvoice"
style="color: primary; width: 100%" style="color: primary; width: 100%"
label="lightning⚡" label="lightning⚡"
> >
@@ -81,7 +131,7 @@
<q-btn <q-btn
flat flat
disable disable
v-if="'{{ charge.onchainwallet }}' == 'None' || charge_time_elapsed == 'True'" v-if="!charge.onchainwallet || charge.time_elapsed"
style="color: primary; width: 100%" style="color: primary; width: 100%"
label="onchain⛓" label="onchain⛓"
> >
@@ -92,7 +142,7 @@
<q-btn <q-btn
flat flat
v-else v-else
@click="payON" @click="payOnchain"
style="color: primary; width: 100%" style="color: primary; width: 100%"
label="onchain⛓" label="onchain⛓"
> >
@@ -103,121 +153,147 @@
<q-separator></q-separator> <q-separator></q-separator>
</div> </div>
</div> </div>
</q-card>
<q-card class="q-pa-lg" v-if="lnbtc"> <q-card class="q-pa-lg" v-if="lnbtc">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<div class="text-center q-pt-md"> <div class="row items-center q-mt-sm">
<div v-if="timetoComplete < 1 && charge_paid == 'False'"> <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 <q-icon
name="block" name="block"
style="color: #ccc; font-size: 21.4em" style="color: #ccc; font-size: 21.4em"
></q-icon> ></q-icon>
</div> </div>
<div v-else-if="charge_paid == 'True'"> <div v-else-if="charge.paid">
<q-icon <q-icon
name="check" name="check"
style="color: green; font-size: 21.4em" style="color: green; font-size: 21.4em"
></q-icon> ></q-icon>
<q-btn <q-btn
outline outline
v-if="'{{ charge.webhook }}' != 'None'" v-if="charge.webhook"
type="a" type="a"
href="{{ charge.completelink }}" :href="charge.completelink"
label="{{ charge.completelinktext }}" :label="charge.completelinktext"
></q-btn> ></q-btn>
</div> </div>
<div v-else> <div v-else>
<center> <div class="row text-center q-mb-sm">
<div class="col text-center">
<span class="text-subtitle2" <span class="text-subtitle2"
>Pay this <br /> >Pay this lightning-network invoice:</span
lightning-network invoice</span
> >
</center> </div>
<a href="lightning:{{ charge.payment_request }}"> </div>
<a :href="'lightning:'+charge.payment_request">
<q-responsive :ratio="1" class="q-mx-md"> <q-responsive :ratio="1" class="q-mx-md">
<qrcode <qrcode
:value="'{{ charge.payment_request }}'" :value="charge.payment_request"
:options="{width: 800}" :options="{width: 800}"
class="rounded-borders" class="rounded-borders"
></qrcode> ></qrcode>
</q-responsive> </q-responsive>
</a> </a>
<div class="row q-mt-lg"> <div class="row text-center q-mt-lg">
<div class="col text-center">
<q-btn <q-btn
outline outline
color="grey" color="grey"
@click="copyText('{{ charge.payment_request }}')" @click="copyText(charge.payment_request)"
>Copy invoice</q-btn >Copy invoice</q-btn
> >
</div> </div>
</div> </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">
<a
style="color: unset"
:href="mempool_endpoint + '/address/' + charge.onchainaddress"
target="_blank"
><span
class="text-subtitle1"
v-text="charge.onchainaddress"
></span>
</a>
</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 <q-icon
name="block" name="block"
style="color: #ccc; font-size: 21.4em" style="color: #ccc; font-size: 21.4em"
></q-icon> ></q-icon>
</div> </div>
<div v-else-if="charge_paid == 'True'"> <div v-else-if="charge.paid">
<q-icon <q-icon
name="check" name="check"
style="color: green; font-size: 21.4em" style="color: green; font-size: 21.4em"
></q-icon> ></q-icon>
<q-btn <q-btn
outline outline
v-if="'{{ charge.webhook }}' != None" v-if="charge.webhook"
type="a" type="a"
href="{{ charge.completelink }}" :href="charge.completelink"
label="{{ charge.completelinktext }}" :label="charge.completelinktext"
></q-btn> ></q-btn>
</div> </div>
<div v-else> <div v-else>
<center> <div class="row items-center q-mb-sm">
<div class="col text-center">
<span class="text-subtitle2" <span class="text-subtitle2"
>Send {{ charge.amount }}sats<br /> >Send
to this onchain address</span
<span v-text="charge.amount"></span>
sats to this onchain address</span
> >
</center> </div>
<a href="bitcoin:{{ charge.onchainaddress }}"> </div>
<a :href="'bitcoin:'+charge.onchainaddress">
<q-responsive :ratio="1" class="q-mx-md"> <q-responsive :ratio="1" class="q-mx-md">
<qrcode <qrcode
:value="'{{ charge.onchainaddress }}'" :value="charge.onchainaddress"
:options="{width: 800}" :options="{width: 800}"
class="rounded-borders" class="rounded-borders"
></qrcode> ></qrcode>
</q-responsive> </q-responsive>
</a> </a>
<div class="row q-mt-lg"> <div class="row items-center q-mt-lg">
<div class="col text-center">
<q-btn <q-btn
outline outline
color="grey" color="grey"
@click="copyText('{{ charge.onchainaddress }}')" @click="copyText(charge.onchainaddress)"
>Copy address</q-btn >Copy address</q-btn
> >
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="col-md-2 col-sm-0"></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
self.charge_balance = response.data.balance
if (self.charge_balance >= self.charge_amount) {
self.charge_paid = 'True'
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(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'" />
</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"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.description}}</a
>
</q-td>
<q-td key="timeLeft" :props="props" :class="">
<div>{{props.row.timeLeft}}</div>
<q-linear-progress
v-if="props.row.timeLeft"
:value="props.row.progress"
color="secondary"
>
</q-linear-progress>
</q-td>
<q-td key="time to pay" :props="props" :class="">
<div>{{props.row.time}}</div>
</q-td>
<q-td key="amount" :props="props" :class="">
<div>{{props.row.amount}}</div>
</q-td>
<q-td key="balance" :props="props" :class="">
<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" type="a"
:href="props.row.displayUrl" :href="props.row.displayUrl"
target="_blank" target="_blank"
class="float-left q-mr-lg"
>Details</q-btn
> >
<q-tooltip> Payment link </q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn <q-btn
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
unelevated unelevated
flat color="gray"
dense outline
size="xs" type="a"
icon="error" @click="refreshBalance(props.row)"
:color="($q.dark.isActive) ? 'red' : 'red'" target="_blank"
class="float-left"
>Refresh Balance</q-btn
> >
<q-tooltip> Time elapsed </q-tooltip> </div>
</q-btn> <div class="col-4 q-pr-lg">
<q-btn <q-btn
v-else-if="props.row.balance >= props.row.amount"
unelevated 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" color="pink"
icon="cancel"
@click="deleteChargeLink(props.row.id)"
>Delete</q-btn
> >
<q-tooltip> Delete charge </q-tooltip> </div>
</q-btn> <div class="col-4"></div>
</q-td> <div class="col-2 q-pr-lg"></div>
<q-td </div>
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-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, onchain: false,
onchainwallet: '',
lnbits: false, lnbits: false,
description: '', description: '',
time: null, time: null,
amount: null amount: null
} }
},
qrCodeDialog: {
show: false,
data: null
} }
} }
}, },
methods: { methods: {
cancelCharge: function (data) { cancelCharge: function (data) {
var self = this this.formDialogCharge.data.description = ''
self.formDialogCharge.data.description = '' this.formDialogCharge.data.onchainwallet = ''
self.formDialogCharge.data.onchainwallet = '' this.formDialogCharge.data.lnbitswallet = ''
self.formDialogCharge.data.lnbitswallet = '' this.formDialogCharge.data.time = null
self.formDialogCharge.data.time = null this.formDialogCharge.data.amount = null
self.formDialogCharge.data.amount = null this.formDialogCharge.data.webhook = ''
self.formDialogCharge.data.webhook = '' this.formDialogCharge.data.completelink = ''
self.formDialogCharge.data.completelink = '' this.formDialogCharge.show = false
self.formDialogCharge.show = false
}, },
getWalletLinks: function () { getWalletLinks: async function () {
var self = this try {
const {data} = await LNbits.api.request(
LNbits.api
.request(
'GET', 'GET',
'/watchonly/api/v1/wallet', '/watchonly/api/v1/wallet',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(function (response) { this.walletLinks = data.map(w => ({
for (i = 0; i < response.data.length; i++) { id: w.id,
self.walletLinks.push(response.data[i].id) label: w.title + ' - ' + w.id
} }))
return } catch (error) {
})
.catch(function (error) {
LNbits.utils.notifyApiError(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 getWalletConfig: async function () {
.request( 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', 'GET',
'/satspay/api/v1/charges', '/satspay/api/v1/charges',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(function (response) { this.chargeLinks = data.map(c =>
self.ChargeLinks = response.data.map(mapCharge) mapCharge(
}) c,
.catch(function (error) { this.chargeLinks.find(old => old.id === c.id)
)
)
} catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) }
}, },
sendFormDataCharge: function () { sendFormDataCharge: function () {
var self = this const wallet = this.g.user.wallets[0].inkey
var wallet = this.g.user.wallets[0].inkey const data = this.formDialogCharge.data
var data = this.formDialogCharge.data
data.amount = parseInt(data.amount) data.amount = parseInt(data.amount)
data.time = parseInt(data.time) data.time = parseInt(data.time)
data.onchainwallet = this.onchainwallet?.id
this.createCharge(wallet, data) this.createCharge(wallet, data)
}, },
timerCount: function () { refreshActiveChargesBalance: async function () {
self = this try {
var refreshIntervalId = setInterval(function () { const activeLinkIds = this.chargeLinks
for (i = 0; i < self.ChargeLinks.length - 1; i++) { .filter(c => !c.paid && !c.time_elapsed && !c.hasStaleBalance)
if (self.ChargeLinks[i]['paid'] == 'True') { .map(c => c.id)
setTimeout(function () { .join(',')
LNbits.api if (activeLinkIds) {
.request( await LNbits.api.request(
'GET', 'GET',
'/satspay/api/v1/charges/balance/' + '/satspay/api/v1/charges/balance/' + activeLinkIds,
self.ChargeLinks[i]['id'],
'filla' 'filla'
) )
.then(function (response) {})
}, 2000)
} }
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
await this.getCharges()
} }
self.getCharges()
}, 20000)
}, },
createCharge: function (wallet, data) { refreshBalance: async function (charge) {
var self = this 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
LNbits.api const {
.request('POST', '/satspay/api/v1/charge', wallet, data) bitcoin: {addresses: addressesAPI}
.then(function (response) { } = mempoolJS({hostname: this.mempool.hostname})
self.ChargeLinks.push(mapCharge(response.data))
self.formDialogCharge.show = false try {
self.formDialogCharge.data = { 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) {
.catch(function (error) {
LNbits.utils.notifyApiError(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 (error) {
.catch(function (error) {
LNbits.utils.notifyApiError(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()