Merge remote-tracking branch 'origin/main' into universalwebsocket

This commit is contained in:
ben 2022-11-28 12:29:34 +00:00
commit 00123d6c16
14 changed files with 235 additions and 128 deletions

View File

@ -6,6 +6,7 @@ PORT=5000
DEBUG=false
# Allow users and admins by user IDs (comma separated list)
LNBITS_ALLOWED_USERS=""
LNBITS_ADMIN_USERS=""
# Extensions only admin can access

View File

@ -157,30 +157,29 @@ class CreateInvoiceData(BaseModel):
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
if data.description_hash:
if data.description_hash or data.unhashed_description:
try:
description_hash = binascii.unhexlify(data.description_hash)
description_hash = (
binascii.unhexlify(data.description_hash)
if data.description_hash
else b""
)
unhashed_description = (
binascii.unhexlify(data.unhashed_description)
if data.unhashed_description
else b""
)
except binascii.Error:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' must be a valid hex string",
detail="'description_hash' and 'unhashed_description' must be a valid hex strings",
)
unhashed_description = b""
memo = ""
elif data.unhashed_description:
try:
unhashed_description = binascii.unhexlify(data.unhashed_description)
except binascii.Error:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'unhashed_description' must be a valid hex string",
)
description_hash = b""
memo = ""
else:
description_hash = b""
unhashed_description = b""
memo = data.memo or LNBITS_SITE_TITLE
if data.unit == "sat":
amount = int(data.amount)
else:

View File

@ -487,6 +487,17 @@
@click="copyText(lnurlValue, 'LNURL copied to clipboard!')"
>Copy LNURL</q-btn
>
<q-chip
v-if="websocketMessage == 'WebSocket NOT supported by your Browser!' || websocketMessage == 'Connection closed'"
clickable
color="red"
text-color="white"
icon="error"
>{% raw %}{{ wsMessage }}{% endraw %}</q-chip
>
<q-chip v-else clickable color="green" text-color="white" icon="check"
>{% raw %}{{ wsMessage }}{% endraw %}</q-chip
>
<br />
<div class="row q-mt-lg q-gutter-sm">
<q-btn
@ -534,6 +545,7 @@
filter: '',
currency: 'USD',
lnurlValue: '',
websocketMessage: '',
switches: 0,
lnurldeviceLinks: [],
lnurldeviceLinksObj: [],
@ -622,6 +634,11 @@
}
}
},
computed: {
wsMessage: function () {
return this.websocketMessage
}
},
methods: {
openQrCodeDialog: function (lnurldevice_id) {
var lnurldevice = _.findWhere(this.lnurldeviceLinks, {
@ -631,11 +648,17 @@
this.qrCodeDialog.data = _.clone(lnurldevice)
this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host
this.lnurlValueFetch(this.qrCodeDialog.data.switches[0][3])
this.lnurlValueFetch(
this.qrCodeDialog.data.switches[0][3],
this.qrCodeDialog.data.id
)
this.qrCodeDialog.show = true
},
lnurlValueFetch: function (lnurl) {
lnurlValueFetch: function (lnurl, switchId) {
this.lnurlValue = lnurl
this.websocketConnector(
'wss://' + window.location.host + '/lnurldevice/ws/' + switchId
)
},
addSwitch: function () {
var self = this
@ -797,6 +820,25 @@
LNbits.utils.notifyApiError(error)
})
},
websocketConnector: function (websocketUrl) {
if ('WebSocket' in window) {
self = this
var ws = new WebSocket(websocketUrl)
self.updateWsMessage('Websocket connected')
ws.onmessage = function (evt) {
var received_msg = evt.data
self.updateWsMessage('Message recieved: ' + received_msg)
}
ws.onclose = function () {
self.updateWsMessage('Connection closed')
}
} else {
self.updateWsMessage('WebSocket NOT supported by your Browser!')
}
},
updateWsMessage: function (message) {
this.websocketMessage = message
},
clearFormDialoglnurldevice() {
this.formDialoglnurldevice.data = {
lnurl_toggle: false,

View File

@ -1,15 +1,15 @@
import json
from typing import List, Optional
import httpx
from loguru import logger
from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment
from lnbits.helpers import urlsafe_short_hash
from ..watchonly.crud import get_config, get_fresh_address
# from lnbits.db import open_ext_db
from . import db
from .helpers import fetch_onchain_balance
from .models import Charges, CreateCharge
###############CHARGES##########################
@ -18,6 +18,10 @@ from .models import Charges, CreateCharge
async def create_charge(user: str, data: CreateCharge) -> Charges:
charge_id = urlsafe_short_hash()
if data.onchainwallet:
config = await get_config(user)
data.extra = json.dumps(
{"mempool_endpoint": config.mempool_endpoint, "network": config.network}
)
onchain = await get_fresh_address(data.onchainwallet)
onchainaddress = onchain.address
else:
@ -48,9 +52,10 @@ async def create_charge(user: str, data: CreateCharge) -> Charges:
completelinktext,
time,
amount,
balance
balance,
extra
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
charge_id,
@ -67,6 +72,7 @@ async def create_charge(user: str, data: CreateCharge) -> Charges:
data.time,
data.amount,
0,
data.extra,
),
)
return await get_charge(charge_id)
@ -98,34 +104,20 @@ async def delete_charge(charge_id: str) -> None:
await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,))
async def check_address_balance(charge_id: str) -> List[Charges]:
async def check_address_balance(charge_id: str) -> Optional[Charges]:
charge = await get_charge(charge_id)
if not charge.paid:
if charge.onchainaddress:
config = await get_charge_config(charge_id)
try:
async with httpx.AsyncClient() as client:
r = await client.get(
config.mempool_endpoint
+ "/api/address/"
+ charge.onchainaddress
)
respAmount = r.json()["chain_stats"]["funded_txo_sum"]
if respAmount > charge.balance:
await update_charge(charge_id=charge_id, balance=respAmount)
except Exception:
pass
respAmount = await fetch_onchain_balance(charge)
if respAmount > charge.balance:
await update_charge(charge_id=charge_id, balance=respAmount)
except Exception as e:
logger.warning(e)
if charge.lnbitswallet:
invoice_status = await api_payment(charge.payment_hash)
if invoice_status["paid"]:
return await update_charge(charge_id=charge_id, balance=charge.amount)
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
return Charges.from_row(row) if row else None
async def get_charge_config(charge_id: str):
row = await db.fetchone(
"""SELECT "user" FROM satspay.charges WHERE id = ?""", (charge_id,)
)
return await get_config(row.user)
return await get_charge(charge_id)

View File

@ -1,8 +1,11 @@
import httpx
from loguru import logger
from .models import Charges
def compact_charge(charge: Charges):
return {
def public_charge(charge: Charges):
c = {
"id": charge.id,
"description": charge.description,
"onchainaddress": charge.onchainaddress,
@ -13,5 +16,38 @@ def compact_charge(charge: Charges):
"balance": charge.balance,
"paid": charge.paid,
"timestamp": charge.timestamp,
"completelink": charge.completelink, # should be secret?
"time_elapsed": charge.time_elapsed,
"time_left": charge.time_left,
"paid": charge.paid,
}
if charge.paid:
c["completelink"] = charge.completelink
return c
async def call_webhook(charge: Charges):
async with httpx.AsyncClient() as client:
try:
r = await client.post(
charge.webhook,
json=public_charge(charge),
timeout=40,
)
return {"webhook_success": r.is_success, "webhook_message": r.reason_phrase}
except Exception as e:
logger.warning(f"Failed to call webhook for charge {charge.id}")
logger.warning(e)
return {"webhook_success": False, "webhook_message": str(e)}
async def fetch_onchain_balance(charge: Charges):
endpoint = (
f"{charge.config.mempool_endpoint}/testnet"
if charge.config.network == "Testnet"
else charge.config.mempool_endpoint
)
async with httpx.AsyncClient() as client:
r = await client.get(endpoint + "/api/address/" + charge.onchainaddress)
return r.json()["chain_stats"]["funded_txo_sum"]

View File

@ -26,3 +26,14 @@ async def m001_initial(db):
);
"""
)
async def m002_add_charge_extra_data(db):
"""
Add 'extra' column for storing various config about the charge (JSON format)
"""
await db.execute(
"""ALTER TABLE satspay.charges
ADD COLUMN extra TEXT DEFAULT '{"mempool_endpoint": "https://mempool.space", "network": "Mainnet"}';
"""
)

View File

@ -1,3 +1,4 @@
import json
from datetime import datetime, timedelta
from sqlite3 import Row
from typing import Optional
@ -15,6 +16,14 @@ class CreateCharge(BaseModel):
completelinktext: str = Query(None)
time: int = Query(..., ge=1)
amount: int = Query(..., ge=1)
extra: str = "{}"
class ChargeConfig(BaseModel):
mempool_endpoint: Optional[str]
network: Optional[str]
webhook_success: Optional[bool] = False
webhook_message: Optional[str]
class Charges(BaseModel):
@ -28,6 +37,7 @@ class Charges(BaseModel):
webhook: Optional[str]
completelink: Optional[str]
completelinktext: Optional[str] = "Back to Merchant"
extra: str = "{}"
time: int
amount: int
balance: int
@ -54,3 +64,11 @@ class Charges(BaseModel):
return True
else:
return False
@property
def config(self) -> ChargeConfig:
charge_config = json.loads(self.extra)
return ChargeConfig(**charge_config)
def must_call_webhook(self):
return self.webhook and self.paid and self.config.webhook_success == False

View File

@ -14,15 +14,14 @@ const retryWithDelay = async function (fn, retryCount = 0) {
}
const mapCharge = (obj, oldObj = {}) => {
const charge = _.clone(obj)
const charge = {...oldObj, ...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.expanded = oldObj.expanded || false
charge.pendingBalance = oldObj.pendingBalance || 0
return charge
}

View File

@ -1,4 +1,5 @@
import asyncio
import json
from loguru import logger
@ -7,7 +8,8 @@ from lnbits.extensions.satspay.crud import check_address_balance, get_charge
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
# from .crud import get_ticket, set_ticket_paid
from .crud import update_charge
from .helpers import call_webhook
async def wait_for_paid_invoices():
@ -30,4 +32,9 @@ async def on_invoice_paid(payment: Payment) -> None:
return
await payment.set_pending(False)
await check_address_balance(charge_id=charge.id)
charge = await check_address_balance(charge_id=charge.id)
if charge.must_call_webhook():
resp = await call_webhook(charge)
extra = {**charge.config.dict(), **resp}
await update_charge(charge_id=charge.id, extra=json.dumps(extra))

View File

@ -109,7 +109,7 @@
<q-btn
flat
disable
v-if="!charge.lnbitswallet || charge.time_elapsed"
v-if="!charge.payment_request || charge.time_elapsed"
style="color: primary; width: 100%"
label="lightning⚡"
>
@ -131,7 +131,7 @@
<q-btn
flat
disable
v-if="!charge.onchainwallet || charge.time_elapsed"
v-if="!charge.onchainaddress || charge.time_elapsed"
style="color: primary; width: 100%"
label="onchain⛓"
>
@ -170,13 +170,17 @@
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 class="row text-center q-mt-lg">
<div class="col text-center">
<q-btn
outline
v-if="charge.webhook"
type="a"
:href="charge.completelink"
:label="charge.completelinktext"
></q-btn>
</div>
</div>
</div>
<div v-else>
<div class="row text-center q-mb-sm">
@ -218,7 +222,7 @@
<div class="col text-center">
<a
style="color: unset"
:href="mempool_endpoint + '/address/' + charge.onchainaddress"
:href="'https://' + mempoolHostname + '/address/' + charge.onchainaddress"
target="_blank"
><span
class="text-subtitle1"
@ -241,13 +245,17 @@
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 class="row text-center q-mt-lg">
<div class="col text-center">
<q-btn
outline
v-if="charge.webhook"
type="a"
:href="charge.completelink"
:label="charge.completelinktext"
></q-btn>
</div>
</div>
</div>
<div v-else>
<div class="row items-center q-mb-sm">
@ -303,7 +311,8 @@
data() {
return {
charge: JSON.parse('{{charge_data | tojson}}'),
mempool_endpoint: '{{mempool_endpoint}}',
mempoolEndpoint: '{{mempool_endpoint}}',
network: '{{network}}',
pendingFunds: 0,
ws: null,
newProgress: 0.4,
@ -316,19 +325,19 @@
cancelListener: () => {}
}
},
computed: {
mempoolHostname: function () {
let hostname = new URL(this.mempoolEndpoint).hostname
if (this.network === 'Testnet') {
hostname += '/testnet'
}
return hostname
}
},
methods: {
startPaymentNotifier() {
this.cancelListener()
if (!this.lnbitswallet) return
this.cancelListener = LNbits.events.onInvoicePaid(
this.wallet,
payment => {
this.checkInvoiceBalance()
}
)
},
checkBalances: async function () {
if (this.charge.hasStaleBalance) return
if (!this.charge.payment_request && this.charge.hasOnchainStaleBalance)
return
try {
const {data} = await LNbits.api.request(
'GET',
@ -345,7 +354,7 @@
const {
bitcoin: {addresses: addressesAPI}
} = mempoolJS({
hostname: new URL(this.mempool_endpoint).hostname
hostname: new URL(this.mempoolEndpoint).hostname
})
try {
@ -353,7 +362,8 @@
address: this.charge.onchainaddress
})
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
this.charge.hasStaleBalance = this.charge.balance === newBalance
this.charge.hasOnchainStaleBalance =
this.charge.balance === newBalance
this.pendingFunds = utxos
.filter(u => !u.status.confirmed)
@ -388,10 +398,10 @@
const {
bitcoin: {websocket}
} = mempoolJS({
hostname: new URL(this.mempool_endpoint).hostname
hostname: new URL(this.mempoolEndpoint).hostname
})
this.ws = new WebSocket('wss://mempool.space/api/v1/ws')
this.ws = new WebSocket(`wss://${this.mempoolHostname}/api/v1/ws`)
this.ws.addEventListener('open', x => {
if (this.charge.onchainaddress) {
this.trackAddress(this.charge.onchainaddress)
@ -428,13 +438,10 @@
}
},
created: async function () {
if (this.charge.lnbitswallet) this.payInvoice()
if (this.charge.payment_request) this.payInvoice()
else this.payOnchain()
await this.checkBalances()
// empty for onchain
this.wallet.inkey = '{{ wallet_inkey }}'
this.startPaymentNotifier()
await this.checkBalances()
if (!this.charge.paid) {
this.loopRefresh()

View File

@ -203,9 +203,14 @@
:href="props.row.webhook"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.webhook || props.row.webhook}}</a
>{{props.row.webhook}}</a
>
</div>
<div class="col-4 q-pr-lg">
<q-badge v-if="props.row.webhook_message" color="blue">
{{props.row.webhook_message }}
</q-badge>
</div>
</div>
<div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg">ID:</div>
@ -409,10 +414,11 @@
balance: null,
walletLinks: [],
chargeLinks: [],
onchainwallet: '',
onchainwallet: null,
rescanning: false,
mempool: {
endpoint: ''
endpoint: '',
network: 'Mainnet'
},
chargesTable: {
@ -505,6 +511,7 @@
methods: {
cancelCharge: function (data) {
this.formDialogCharge.data.description = ''
this.formDialogCharge.data.onchain = false
this.formDialogCharge.data.onchainwallet = ''
this.formDialogCharge.data.lnbitswallet = ''
this.formDialogCharge.data.time = null
@ -518,7 +525,7 @@
try {
const {data} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/wallet',
`/watchonly/api/v1/wallet?network=${this.mempool.network}`,
this.g.user.wallets[0].inkey
)
this.walletLinks = data.map(w => ({
@ -538,6 +545,7 @@
this.g.user.wallets[0].inkey
)
this.mempool.endpoint = data.mempool_endpoint
this.mempool.network = data.network || 'Mainnet'
const url = new URL(this.mempool.endpoint)
this.mempool.hostname = url.hostname
} catch (error) {
@ -577,7 +585,8 @@
const data = this.formDialogCharge.data
data.amount = parseInt(data.amount)
data.time = parseInt(data.time)
data.onchainwallet = this.onchainwallet?.id
data.lnbitswallet = data.lnbits ? data.lnbitswallet : null
data.onchainwallet = data.onchain ? this.onchainwallet?.id : null
this.createCharge(wallet, data)
},
refreshActiveChargesBalance: async function () {
@ -695,8 +704,8 @@
},
created: async function () {
await this.getCharges()
await this.getWalletLinks()
await this.getWalletConfig()
await this.getWalletLinks()
setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000)
await this.rescanOnchainAddresses()
setInterval(() => this.rescanOnchainAddresses(), 10 * 1000)

View File

@ -6,12 +6,12 @@ from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import HTMLResponse
from lnbits.core.crud import get_wallet
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.extensions.satspay.helpers import public_charge
from . import satspay_ext, satspay_renderer
from .crud import get_charge, get_charge_config
from .crud import get_charge
templates = Jinja2Templates(directory="templates")
@ -30,18 +30,13 @@ async def display(request: Request, charge_id: str):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist."
)
wallet = await get_wallet(charge.lnbitswallet)
onchainwallet_config = await get_charge_config(charge_id)
inkey = wallet.inkey if wallet else None
mempool_endpoint = (
onchainwallet_config.mempool_endpoint if onchainwallet_config else None
)
return satspay_renderer().TemplateResponse(
"satspay/display.html",
{
"request": request,
"charge_data": charge.dict(),
"wallet_inkey": inkey,
"mempool_endpoint": mempool_endpoint,
"charge_data": public_charge(charge),
"mempool_endpoint": charge.config.mempool_endpoint,
"network": charge.config.network,
},
)

View File

@ -1,6 +1,6 @@
import json
from http import HTTPStatus
import httpx
from fastapi.params import Depends
from starlette.exceptions import HTTPException
@ -20,7 +20,7 @@ from .crud import (
get_charges,
update_charge,
)
from .helpers import compact_charge
from .helpers import call_webhook, public_charge
from .models import CreateCharge
#############################CHARGES##########################
@ -58,6 +58,7 @@ async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
**{"time_elapsed": charge.time_elapsed},
**{"time_left": charge.time_left},
**{"paid": charge.paid},
**{"webhook_message": charge.config.webhook_message},
}
for charge in await get_charges(wallet.wallet.user)
]
@ -119,19 +120,9 @@ async def api_charge_balance(charge_id):
status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist."
)
if charge.paid and charge.webhook:
async with httpx.AsyncClient() as client:
try:
r = await client.post(
charge.webhook,
json=compact_charge(charge),
timeout=40,
)
except AssertionError:
charge.webhook = None
return {
**compact_charge(charge),
**{"time_elapsed": charge.time_elapsed},
**{"time_left": charge.time_left},
**{"paid": charge.paid},
}
if charge.must_call_webhook():
resp = await call_webhook(charge)
extra = {**charge.config.dict(), **resp}
await update_charge(charge_id=charge.id, extra=json.dumps(extra))
return {**public_charge(charge)}

View File

@ -124,7 +124,7 @@ async def check_pending_payments():
while True:
async with db.connect() as conn:
logger.debug(
logger.info(
f"Task: checking all pending payments (incoming={incoming}, outgoing={outgoing}) of last 15 days"
)
start_time: float = time.time()
@ -140,15 +140,15 @@ async def check_pending_payments():
for payment in pending_payments:
await payment.check_status(conn=conn)
logger.debug(
logger.info(
f"Task: pending check finished for {len(pending_payments)} payments (took {time.time() - start_time:0.3f} s)"
)
# we delete expired invoices once upon the first pending check
if incoming:
logger.debug("Task: deleting all expired invoices")
logger.info("Task: deleting all expired invoices")
start_time: float = time.time()
await delete_expired_invoices(conn=conn)
logger.debug(
logger.info(
f"Task: expired invoice deletion finished (took {time.time() - start_time:0.3f} s)"
)