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
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)"
)