Merge pull request #1148 from motorina0/satspay_fix

fix `staspay` webhook for LN payment
This commit is contained in:
Arc 2022-11-25 07:29:34 -08:00 committed by GitHub
commit e1dfdbba76
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 177 additions and 112 deletions

View file

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

View file

@ -1,8 +1,11 @@
import httpx
from loguru import logger
from .models import Charges from .models import Charges
def compact_charge(charge: Charges): def public_charge(charge: Charges):
return { c = {
"id": charge.id, "id": charge.id,
"description": charge.description, "description": charge.description,
"onchainaddress": charge.onchainaddress, "onchainaddress": charge.onchainaddress,
@ -13,5 +16,38 @@ def compact_charge(charge: Charges):
"balance": charge.balance, "balance": charge.balance,
"paid": charge.paid, "paid": charge.paid,
"timestamp": charge.timestamp, "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 datetime import datetime, timedelta
from sqlite3 import Row from sqlite3 import Row
from typing import Optional from typing import Optional
@ -15,6 +16,14 @@ class CreateCharge(BaseModel):
completelinktext: str = Query(None) completelinktext: str = Query(None)
time: int = Query(..., ge=1) time: int = Query(..., ge=1)
amount: 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): class Charges(BaseModel):
@ -28,6 +37,7 @@ class Charges(BaseModel):
webhook: Optional[str] webhook: Optional[str]
completelink: Optional[str] completelink: Optional[str]
completelinktext: Optional[str] = "Back to Merchant" completelinktext: Optional[str] = "Back to Merchant"
extra: str = "{}"
time: int time: int
amount: int amount: int
balance: int balance: int
@ -54,3 +64,11 @@ class Charges(BaseModel):
return True return True
else: else:
return False 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 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.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time
charge.time = minutesToTime(obj.time) charge.time = minutesToTime(obj.time)
charge.timeLeft = minutesToTime(obj.time_left) charge.timeLeft = minutesToTime(obj.time_left)
charge.expanded = false
charge.displayUrl = ['/satspay/', obj.id].join('') charge.displayUrl = ['/satspay/', obj.id].join('')
charge.expanded = oldObj.expanded charge.expanded = oldObj.expanded || false
charge.pendingBalance = oldObj.pendingBalance || 0 charge.pendingBalance = oldObj.pendingBalance || 0
return charge return charge
} }

View file

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

View file

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

View file

@ -6,12 +6,12 @@ from starlette.exceptions import HTTPException
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
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.satspay.helpers import public_charge
from . import satspay_ext, satspay_renderer from . import satspay_ext, satspay_renderer
from .crud import get_charge, get_charge_config from .crud import get_charge
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@ -30,18 +30,13 @@ async def display(request: Request, charge_id: str):
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)
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( return satspay_renderer().TemplateResponse(
"satspay/display.html", "satspay/display.html",
{ {
"request": request, "request": request,
"charge_data": charge.dict(), "charge_data": public_charge(charge),
"wallet_inkey": inkey, "mempool_endpoint": charge.config.mempool_endpoint,
"mempool_endpoint": mempool_endpoint, "network": charge.config.network,
}, },
) )

View file

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

View file

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