Merge branch 'lnbits:main' into main

This commit is contained in:
blackcoffeexbt
2022-10-12 12:08:29 +02:00
committed by GitHub
30 changed files with 655 additions and 81 deletions

View File

@@ -37,11 +37,11 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
LNBITS_SITE_TITLE="LNbits" LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet" LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'" LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
# Choose from mint, flamingo, freedom, salvador, autumn, monochrome, classic # Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic
LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador" LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador"
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg" # LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet # Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet, LnTipsWallet
# LndRestWallet, CoreLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet # LndRestWallet, CoreLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
LNBITS_BACKEND_WALLET_CLASS=VoidWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities, # VoidWallet is just a fallback that works without any actual Lightning capabilities,
@@ -92,3 +92,8 @@ LNBITS_DENOMINATION=sats
# EclairWallet # EclairWallet
ECLAIR_URL=http://127.0.0.1:8283 ECLAIR_URL=http://127.0.0.1:8283
ECLAIR_PASS=eclairpw ECLAIR_PASS=eclairpw
# LnTipsWallet
# Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips

87
docs/devs/websockets.md Normal file
View File

@@ -0,0 +1,87 @@
---
layout: default
parent: For developers
title: Websockets
nav_order: 2
---
Websockets
=================
`websockets` are a great way to add a two way instant data channel between server and client. This example was taken from the `copilot` extension, we create a websocket endpoint which can be restricted by `id`, then can feed it data to broadcast to any client on the socket using the `updater(extension_id, data)` function (`extension` has been used in place of an extension name, wreplace to your own extension):
```sh
from fastapi import Request, WebSocket, WebSocketDisconnect
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, extension_id: str):
await websocket.accept()
websocket.id = extension_id
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, extension_id: str):
for connection in self.active_connections:
if connection.id == extension_id:
await connection.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@extension_ext.websocket("/ws/{extension_id}", name="extension.websocket_by_id")
async def websocket_endpoint(websocket: WebSocket, extension_id: str):
await manager.connect(websocket, extension_id)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
async def updater(extension_id, data):
extension = await get_extension(extension_id)
if not extension:
return
await manager.send_personal_message(f"{data}", extension_id)
```
Example vue-js function for listening to the websocket:
```
initWs: async function () {
if (location.protocol !== 'http:') {
localUrl =
'wss://' +
document.domain +
':' +
location.port +
'/extension/ws/' +
self.extension.id
} else {
localUrl =
'ws://' +
document.domain +
':' +
location.port +
'/extension/ws/' +
self.extension.id
}
this.ws = new WebSocket(localUrl)
this.ws.addEventListener('message', async ({data}) => {
const res = JSON.parse(data.toString())
console.log(res)
})
},
```

View File

@@ -18,21 +18,25 @@ If you have problems installing LNbits using these instructions, please have a l
git clone https://github.com/lnbits/lnbits-legend.git git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/ cd lnbits-legend/
# for making sure python 3.9 is installed, skip if installed # for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
sudo apt update sudo apt update
sudo apt install software-properties-common sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 python3.9-distutils sudo apt install python3.9 python3.9-distutils
curl -sSL https://install.python-poetry.org | python3 - curl -sSL https://install.python-poetry.org | python3 -
export PATH="/home/ubuntu/.local/bin:$PATH" # or whatever is suggested in the poetry install notes printed to terminal # Once the above poetry install is completed, use the installation path printed to terminal and replace in the following command
export PATH="/home/user/.local/bin:$PATH"
# Next command, you can exchange with python3.10 or newer versions.
# Identify your version with python3 --version and specify in the next line
# command is only needed when your default python is not ^3.9 or ^3.10
poetry env use python3.9 poetry env use python3.9
poetry install --no-dev poetry install --only main
poetry run python build.py
mkdir data mkdir data
cp .env.example .env cp .env.example .env
nano .env # set funding source # set funding source amongst other options
nano .env
``` ```
#### Running the server #### Running the server
@@ -40,6 +44,8 @@ nano .env # set funding source
```sh ```sh
poetry run lnbits poetry run lnbits
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0' # To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
# Note that you have to add the line DEBUG=true in your .env file, too.
``` ```
## Option 2: Nix ## Option 2: Nix

View File

@@ -126,7 +126,7 @@ def check_funding_source(app: FastAPI) -> None:
logger.info("Retrying connection to backend in 5 seconds...") logger.info("Retrying connection to backend in 5 seconds...")
await asyncio.sleep(5) await asyncio.sleep(5)
signal.signal(signal.SIGINT, original_sigint_handler) signal.signal(signal.SIGINT, original_sigint_handler)
logger.info( logger.success(
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat." f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
) )

View File

@@ -333,7 +333,7 @@ async def delete_expired_invoices(
""" """
) )
logger.debug(f"Checking expiry of {len(rows)} invoices") logger.debug(f"Checking expiry of {len(rows)} invoices")
for (payment_request,) in rows: for i, (payment_request,) in enumerate(rows):
try: try:
invoice = bolt11.decode(payment_request) invoice = bolt11.decode(payment_request)
except: except:
@@ -343,7 +343,7 @@ async def delete_expired_invoices(
if expiration_date > datetime.datetime.utcnow(): if expiration_date > datetime.datetime.utcnow():
continue continue
logger.debug( logger.debug(
f"Deleting expired invoice: {invoice.payment_hash} (expired: {expiration_date})" f"Deleting expired invoice {i}/{len(rows)}: {invoice.payment_hash} (expired: {expiration_date})"
) )
await (conn or db).execute( await (conn or db).execute(
""" """

View File

@@ -171,6 +171,17 @@
</a> </a>
</div> </div>
</div> </div>
<div class="row">
<div class="col">
<a href="https://mynodebtc.com">
<q-img
contain
:src="($q.dark.isActive) ? '/static/images/mynode.png' : '/static/images/mynodel.png'"
></q-img>
</a>
</div>
<div class="col q-pl-md">&nbsp;</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -52,6 +52,12 @@ class Compat:
return "" return ""
return "<nothing>" return "<nothing>"
@property
def big_int(self) -> str:
if self.type in {POSTGRES}:
return "BIGINT"
return "INT"
class Connection(Compat): class Connection(Compat):
def __init__(self, conn: AsyncConnection, txn, typ, name, schema): def __init__(self, conn: AsyncConnection, txn, typ, name, schema):

View File

@@ -29,7 +29,7 @@ async def m001_initial(db):
) )
await db.execute( await db.execute(
""" f"""
CREATE TABLE boltcards.hits ( CREATE TABLE boltcards.hits (
id TEXT PRIMARY KEY UNIQUE, id TEXT PRIMARY KEY UNIQUE,
card_id TEXT NOT NULL, card_id TEXT NOT NULL,
@@ -38,7 +38,7 @@ async def m001_initial(db):
useragent TEXT, useragent TEXT,
old_ctr INT NOT NULL DEFAULT 0, old_ctr INT NOT NULL DEFAULT 0,
new_ctr INT NOT NULL DEFAULT 0, new_ctr INT NOT NULL DEFAULT 0,
amount INT NOT NULL, amount {db.big_int} NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """ time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now + db.timestamp_now
+ """ + """
@@ -47,11 +47,11 @@ async def m001_initial(db):
) )
await db.execute( await db.execute(
""" f"""
CREATE TABLE boltcards.refunds ( CREATE TABLE boltcards.refunds (
id TEXT PRIMARY KEY UNIQUE, id TEXT PRIMARY KEY UNIQUE,
hit_id TEXT NOT NULL, hit_id TEXT NOT NULL,
refund_amount INT NOT NULL, refund_amount {db.big_int} NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """ time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now + db.timestamp_now
+ """ + """

View File

@@ -1,16 +1,16 @@
async def m001_initial(db): async def m001_initial(db):
await db.execute( await db.execute(
""" f"""
CREATE TABLE boltz.submarineswap ( CREATE TABLE boltz.submarineswap (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
payment_hash TEXT NOT NULL, payment_hash TEXT NOT NULL,
amount INT NOT NULL, amount {db.big_int} NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
boltz_id TEXT NOT NULL, boltz_id TEXT NOT NULL,
refund_address TEXT NOT NULL, refund_address TEXT NOT NULL,
refund_privkey TEXT NOT NULL, refund_privkey TEXT NOT NULL,
expected_amount INT NOT NULL, expected_amount {db.big_int} NOT NULL,
timeout_block_height INT NOT NULL, timeout_block_height INT NOT NULL,
address TEXT NOT NULL, address TEXT NOT NULL,
bip21 TEXT NOT NULL, bip21 TEXT NOT NULL,
@@ -22,12 +22,12 @@ async def m001_initial(db):
""" """
) )
await db.execute( await db.execute(
""" f"""
CREATE TABLE boltz.reverse_submarineswap ( CREATE TABLE boltz.reverse_submarineswap (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
onchain_address TEXT NOT NULL, onchain_address TEXT NOT NULL,
amount INT NOT NULL, amount {db.big_int} NOT NULL,
instant_settlement BOOLEAN NOT NULL, instant_settlement BOOLEAN NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
boltz_id TEXT NOT NULL, boltz_id TEXT NOT NULL,
@@ -37,7 +37,7 @@ async def m001_initial(db):
claim_privkey TEXT NOT NULL, claim_privkey TEXT NOT NULL,
lockup_address TEXT NOT NULL, lockup_address TEXT NOT NULL,
invoice TEXT NOT NULL, invoice TEXT NOT NULL,
onchain_amount INT NOT NULL, onchain_amount {db.big_int} NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """ time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now + db.timestamp_now
+ """ + """

View File

@@ -45,7 +45,7 @@ async def m001_initial_invoices(db):
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL, invoice_id TEXT NOT NULL,
amount INT NOT NULL, amount {db.big_int} NOT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},

View File

@@ -1,7 +1,10 @@
import asyncio
from fastapi import APIRouter from fastapi import APIRouter
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_lnurldevice") db = Database("ext_lnurldevice")
@@ -13,5 +16,11 @@ def lnurldevice_renderer():
from .lnurl import * # noqa from .lnurl import * # noqa
from .tasks import wait_for_paid_invoices
from .views import * # noqa from .views import * # noqa
from .views_api import * # noqa from .views_api import * # noqa
def lnurldevice_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@@ -22,9 +22,10 @@ async def create_lnurldevice(
wallet, wallet,
currency, currency,
device, device,
profit profit,
amount
) )
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
lnurldevice_id, lnurldevice_id,
@@ -34,6 +35,7 @@ async def create_lnurldevice(
data.currency, data.currency,
data.device, data.device,
data.profit, data.profit,
data.amount,
), ),
) )
return await get_lnurldevice(lnurldevice_id) return await get_lnurldevice(lnurldevice_id)

View File

@@ -102,7 +102,32 @@ async def lnurl_v1_params(
if device.device == "atm": if device.device == "atm":
if paymentcheck: if paymentcheck:
return {"status": "ERROR", "reason": f"Payment already claimed"} return {"status": "ERROR", "reason": f"Payment already claimed"}
if device.device == "switch":
price_msat = (
await fiat_amount_as_satoshis(float(device.profit), device.currency)
if device.currency != "sat"
else amount_in_cent
) * 1000
lnurldevicepayment = await create_lnurldevicepayment(
deviceid=device.id,
payload="bla",
sats=price_msat,
pin=1,
payhash="bla",
)
if not lnurldevicepayment:
return {"status": "ERROR", "reason": "Could not create payment."}
return {
"tag": "payRequest",
"callback": request.url_for(
"lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id
),
"minSendable": price_msat,
"maxSendable": price_msat,
"metadata": await device.lnurlpay_metadata(),
}
if len(p) % 4 > 0: if len(p) % 4 > 0:
p += "=" * (4 - (len(p) % 4)) p += "=" * (4 - (len(p) % 4))
@@ -184,22 +209,42 @@ async def lnurl_callback(
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="lnurldevice not found." status_code=HTTPStatus.FORBIDDEN, detail="lnurldevice not found."
) )
if pr: if device.device == "atm":
if lnurldevicepayment.id != k1: if not pr:
return {"status": "ERROR", "reason": "Bad K1"} raise HTTPException(
if lnurldevicepayment.payhash != "payment_hash": status_code=HTTPStatus.FORBIDDEN, detail="No payment request"
return {"status": "ERROR", "reason": f"Payment already claimed"} )
else:
if lnurldevicepayment.id != k1:
return {"status": "ERROR", "reason": "Bad K1"}
if lnurldevicepayment.payhash != "payment_hash":
return {"status": "ERROR", "reason": f"Payment already claimed"}
lnurldevicepayment = await update_lnurldevicepayment( lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload
) )
await pay_invoice( await pay_invoice(
wallet_id=device.wallet,
payment_request=pr,
max_sat=lnurldevicepayment.sats / 1000,
extra={"tag": "withdraw"},
)
return {"status": "OK"}
if device.device == "switch":
payment_hash, payment_request = await create_invoice(
wallet_id=device.wallet, wallet_id=device.wallet,
payment_request=pr, amount=lnurldevicepayment.sats / 1000,
max_sat=lnurldevicepayment.sats / 1000, memo=device.title + "-" + lnurldevicepayment.id,
extra={"tag": "withdraw"}, unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "Switch", "id": paymentid, "time": device.amount},
) )
return {"status": "OK"} lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=paymentid, payhash=payment_hash
)
return {
"pr": payment_request,
"routes": [],
}
payment_hash, payment_request = await create_invoice( payment_hash, payment_request = await create_invoice(
wallet_id=device.wallet, wallet_id=device.wallet,
@@ -221,5 +266,3 @@ async def lnurl_callback(
}, },
"routes": [], "routes": [],
} }
return resp.dict()

View File

@@ -29,7 +29,7 @@ async def m001_initial(db):
payhash TEXT, payhash TEXT,
payload TEXT NOT NULL, payload TEXT NOT NULL,
pin INT, pin INT,
sats INT, sats {db.big_int},
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
); );
""" """
@@ -79,3 +79,12 @@ async def m002_redux(db):
) )
except: except:
return return
async def m003_redux(db):
"""
Add 'meta' for storing various metadata about the wallet
"""
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount INT DEFAULT 0;"
)

View File

@@ -17,6 +17,7 @@ class createLnurldevice(BaseModel):
currency: str currency: str
device: str device: str
profit: float profit: float
amount: int
class lnurldevices(BaseModel): class lnurldevices(BaseModel):
@@ -27,15 +28,14 @@ class lnurldevices(BaseModel):
currency: str currency: str
device: str device: str
profit: float profit: float
amount: int
timestamp: str timestamp: str
def from_row(cls, row: Row) -> "lnurldevices": def from_row(cls, row: Row) -> "lnurldevices":
return cls(**dict(row)) return cls(**dict(row))
def lnurl(self, req: Request) -> Lnurl: def lnurl(self, req: Request) -> Lnurl:
url = req.url_for( url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
"lnurldevice.lnurl_response", device_id=self.id, _external=True
)
return lnurl_encode(url) return lnurl_encode(url)
async def lnurlpay_metadata(self) -> LnurlPayMetadata: async def lnurlpay_metadata(self) -> LnurlPayMetadata:

View File

@@ -0,0 +1,40 @@
import asyncio
import json
from http import HTTPStatus
from urllib.parse import urlparse
import httpx
from fastapi import HTTPException
from lnbits import bolt11
from lnbits.core.models import Payment
from lnbits.core.services import pay_invoice
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import get_lnurldevice, get_lnurldevicepayment, update_lnurldevicepayment
from .views import updater
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name())
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
# (avoid loops)
if "Switch" == payment.extra.get("tag"):
lnurldevicepayment = await get_lnurldevicepayment(payment.extra.get("id"))
if not lnurldevicepayment:
return
if lnurldevicepayment.payhash == "used":
return
lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=payment.extra.get("id"), payhash="used"
)
return await updater(lnurldevicepayment.deviceid)
return

View File

@@ -1,13 +1,24 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<p> <p>
Register LNURLDevice devices to receive payments in your LNbits wallet.<br /> For LNURL based Points of Sale, ATMs, and relay devices<br />
Build your own here Use with: <br />
<a href="https://github.com/arcbtc/bitcoinpos" LNPoS
>https://github.com/arcbtc/bitcoinpos</a <a href="https://lnbits.github.io/lnpos">
https://lnbits.github.io/lnpos</a
><br />
bitcoinSwitch
<a href="https://github.com/lnbits/bitcoinSwitch">
https://github.com/lnbits/bitcoinSwitch</a
><br />
FOSSA
<a href="https://github.com/lnbits/fossa">
https://github.com/lnbits/fossa</a
><br /> ><br />
<small> <small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small Created by, <a href="https://github.com/benarc">Ben Arc</a>,
<a href="https://github.com/blackcoffeexbt">BC</a>,
<a href="https://github.com/motorina0">Vlad Stan</a></small
> >
</p> </p>
</q-card-section> </q-card-section>

View File

@@ -51,6 +51,7 @@
<q-tr :props="props"> <q-tr :props="props">
<q-th style="width: 5%"></q-th> <q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th> <q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th <q-th
v-for="col in props.cols" v-for="col in props.cols"
@@ -91,6 +92,22 @@
<q-tooltip> LNURLDevice Settings </q-tooltip> <q-tooltip> LNURLDevice Settings </q-tooltip>
</q-btn> </q-btn>
</q-td> </q-td>
<q-td>
<q-btn
v-if="props.row.device == 'switch'"
:disable="protocol == 'http:'"
flat
unelevated
dense
size="xs"
icon="visibility"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.id)"
><q-tooltip v-if="protocol == 'http:'">
LNURLs only work over HTTPS </q-tooltip
><q-tooltip v-else> view LNURL </q-tooltip></q-btn
>
</q-td>
<q-td <q-td
v-for="col in props.cols" v-for="col in props.cols"
:key="col.name" :key="col.name"
@@ -132,20 +149,33 @@
class="q-pa-lg q-pt-xl lnbits__dialog-card" class="q-pa-lg q-pt-xl lnbits__dialog-card"
> >
<div class="text-h6">LNURLDevice device string</div> <div class="text-h6">LNURLDevice device string</div>
<q-btn <center>
dense <q-btn
outline v-if="settingsDialog.data.device == 'switch'"
unelevated dense
color="primary" outline
size="md" unelevated
@click="copyText(location + '/lnurldevice/api/v1/lnurl/' + settingsDialog.data.id + ',' + color="primary"
size="md"
@click="copyText(wslocation + '/lnurldevice/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')"
>{% raw %}{{wslocation}}/lnurldevice/ws/{{settingsDialog.data.id}}{%
endraw %}<q-tooltip> Click to copy URL </q-tooltip>
</q-btn>
<q-btn
v-else
dense
outline
unelevated
color="primary"
size="md"
@click="copyText(location + '/lnurldevice/api/v1/lnurl/' + settingsDialog.data.id + ',' +
settingsDialog.data.key + ',' + settingsDialog.data.currency, 'Link copied to clipboard!')" settingsDialog.data.key + ',' + settingsDialog.data.currency, 'Link copied to clipboard!')"
>{% raw >{% raw
%}{{location}}/lnurldevice/api/v1/lnurl/{{settingsDialog.data.id}}, %}{{location}}/lnurldevice/api/v1/lnurl/{{settingsDialog.data.id}},
{{settingsDialog.data.key}}, {{settingsDialog.data.currency}}{% endraw {{settingsDialog.data.key}}, {{settingsDialog.data.currency}}{% endraw
%}<q-tooltip> Click to copy URL </q-tooltip> %}<q-tooltip> Click to copy URL </q-tooltip>
</q-btn> </q-btn>
</center>
<div class="text-subtitle2"> <div class="text-subtitle2">
<small> </small> <small> </small>
</div> </div>
@@ -191,6 +221,7 @@
label="Type of device" label="Type of device"
></q-option-group> ></q-option-group>
<q-input <q-input
v-if="formDialoglnurldevice.data.device != 'switch'"
filled filled
dense dense
v-model.trim="formDialoglnurldevice.data.profit" v-model.trim="formDialoglnurldevice.data.profit"
@@ -198,6 +229,29 @@
max="90" max="90"
label="Profit margin (% added to invoices/deducted from faucets)" label="Profit margin (% added to invoices/deducted from faucets)"
></q-input> ></q-input>
<div v-else>
<q-input
ref="setAmount"
filled
dense
v-model.trim="formDialoglnurldevice.data.profit"
class="q-pb-md"
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
:mask="'#.##'"
fill-mask="0"
reverse-fill-mask
:step="'0.01'"
value="0.00"
></q-input>
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.amount"
type="number"
value="1000"
label="milesecs to turn Switch on for (1sec = 1000ms)"
></q-input>
</div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
@@ -225,6 +279,33 @@
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
{% raw %}
</q-responsive>
<p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
</p>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.lnurl, 'LNURL copied to clipboard!')"
class="q-ml-sm"
>Copy LNURL</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
@@ -252,7 +333,9 @@
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
return { return {
protocol: window.location.protocol,
location: window.location.hostname, location: window.location.hostname,
wslocation: window.location.hostname,
filter: '', filter: '',
currency: 'USD', currency: 'USD',
lnurldeviceLinks: [], lnurldeviceLinks: [],
@@ -265,6 +348,10 @@
{ {
label: 'ATM', label: 'ATM',
value: 'atm' value: 'atm'
},
{
label: 'Switch',
value: 'switch'
} }
], ],
lnurldevicesTable: { lnurldevicesTable: {
@@ -333,7 +420,8 @@
show_ack: false, show_ack: false,
show_price: 'None', show_price: 'None',
device: 'pos', device: 'pos',
profit: 2, profit: 0,
amount: 1,
title: '' title: ''
} }
}, },
@@ -344,6 +432,16 @@
} }
}, },
methods: { methods: {
openQrCodeDialog: function (lnurldevice_id) {
var lnurldevice = _.findWhere(this.lnurldeviceLinks, {
id: lnurldevice_id
})
console.log(lnurldevice)
this.qrCodeDialog.data = _.clone(lnurldevice)
this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host
this.qrCodeDialog.show = true
},
cancellnurldevice: function (data) { cancellnurldevice: function (data) {
var self = this var self = this
self.formDialoglnurldevice.show = false self.formDialoglnurldevice.show = false
@@ -400,6 +498,7 @@
.then(function (response) { .then(function (response) {
if (response.data) { if (response.data) {
self.lnurldeviceLinks = response.data.map(maplnurldevice) self.lnurldeviceLinks = response.data.map(maplnurldevice)
console.log(response.data)
} }
}) })
.catch(function (error) { .catch(function (error) {
@@ -519,6 +618,7 @@
'//', '//',
window.location.host window.location.host
].join('') ].join('')
self.wslocation = ['ws://', window.location.host].join('')
LNbits.api LNbits.api
.request('GET', '/api/v1/currencies') .request('GET', '/api/v1/currencies')
.then(response => { .then(response => {

View File

@@ -1,11 +1,13 @@
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO
from fastapi import Request import pyqrcode
from fastapi import Request, WebSocket, WebSocketDisconnect
from fastapi.param_functions import Query from fastapi.param_functions import Query
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse, StreamingResponse
from lnbits.core.crud import update_payment_status from lnbits.core.crud import update_payment_status
from lnbits.core.models import User from lnbits.core.models import User
@@ -51,3 +53,58 @@ async def displaypin(request: Request, paymentid: str = Query(None)):
"lnurldevice/error.html", "lnurldevice/error.html",
{"request": request, "pin": "filler", "not_paid": True}, {"request": request, "pin": "filler", "not_paid": True},
) )
@lnurldevice_ext.get("/img/{lnurldevice_id}", response_class=StreamingResponse)
async def img(request: Request, lnurldevice_id):
lnurldevice = await get_lnurldevice(lnurldevice_id)
if not lnurldevice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURLDevice does not exist."
)
return lnurldevice.lnurl(request)
##################WEBSOCKET ROUTES########################
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, lnurldevice_id: str):
await websocket.accept()
websocket.id = lnurldevice_id
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, lnurldevice_id: str):
for connection in self.active_connections:
if connection.id == lnurldevice_id:
await connection.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@lnurldevice_ext.websocket("/ws/{lnurldevice_id}", name="lnurldevice.lnurldevice_by_id")
async def websocket_endpoint(websocket: WebSocket, lnurldevice_id: str):
await manager.connect(websocket, lnurldevice_id)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
async def updater(lnurldevice_id):
lnurldevice = await get_lnurldevice(lnurldevice_id)
if not lnurldevice:
return
await manager.send_personal_message(f"{lnurldevice.amount}", lnurldevice_id)

View File

@@ -32,32 +32,42 @@ async def api_list_currencies_available():
@lnurldevice_ext.post("/api/v1/lnurlpos") @lnurldevice_ext.post("/api/v1/lnurlpos")
@lnurldevice_ext.put("/api/v1/lnurlpos/{lnurldevice_id}") @lnurldevice_ext.put("/api/v1/lnurlpos/{lnurldevice_id}")
async def api_lnurldevice_create_or_update( async def api_lnurldevice_create_or_update(
req: Request,
data: createLnurldevice, data: createLnurldevice,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
lnurldevice_id: str = Query(None), lnurldevice_id: str = Query(None),
): ):
if not lnurldevice_id: if not lnurldevice_id:
lnurldevice = await create_lnurldevice(data) lnurldevice = await create_lnurldevice(data)
return lnurldevice.dict() return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
else: else:
lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id) lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id)
return lnurldevice.dict() return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
@lnurldevice_ext.get("/api/v1/lnurlpos") @lnurldevice_ext.get("/api/v1/lnurlpos")
async def api_lnurldevices_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): async def api_lnurldevices_retrieve(
req: Request, wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
try: try:
return [ return [
{**lnurldevice.dict()} for lnurldevice in await get_lnurldevices(wallet_ids) {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
for lnurldevice in await get_lnurldevices(wallet_ids)
] ]
except: except:
return "" try:
return [
{**lnurldevice.dict()}
for lnurldevice in await get_lnurldevices(wallet_ids)
]
except:
return ""
@lnurldevice_ext.get("/api/v1/lnurlpos/{lnurldevice_id}") @lnurldevice_ext.get("/api/v1/lnurlpos/{lnurldevice_id}")
async def api_lnurldevice_retrieve( async def api_lnurldevice_retrieve(
request: Request, req: Request,
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type),
lnurldevice_id: str = Query(None), lnurldevice_id: str = Query(None),
): ):
@@ -68,7 +78,7 @@ async def api_lnurldevice_retrieve(
) )
if not lnurldevice.lnurl_toggle: if not lnurldevice.lnurl_toggle:
return {**lnurldevice.dict()} return {**lnurldevice.dict()}
return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(request=request)}} return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
@lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}") @lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}")

View File

@@ -3,14 +3,14 @@ async def m001_initial(db):
Initial lnurlpayouts table. Initial lnurlpayouts table.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE lnurlpayout.lnurlpayouts ( CREATE TABLE lnurlpayout.lnurlpayouts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
admin_key TEXT NOT NULL, admin_key TEXT NOT NULL,
lnurlpay TEXT NOT NULL, lnurlpay TEXT NOT NULL,
threshold INT NOT NULL threshold {db.big_int} NOT NULL
); );
""" """
) )

View File

@@ -4,6 +4,8 @@
SCRUB is a small but handy extension that allows a user to take advantage of all the functionalities inside **LNbits** and upon a payment received to your LNbits wallet, automatically forward it to your desired wallet via LNURL or LNAddress! SCRUB is a small but handy extension that allows a user to take advantage of all the functionalities inside **LNbits** and upon a payment received to your LNbits wallet, automatically forward it to your desired wallet via LNURL or LNAddress!
<small>Only whole values, integers, are Scrubbed, amounts will be rounded down (example: 6.3 will be 6)! The decimals, if existing, will be kept in your wallet!</small>
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) [**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
## Usage ## Usage

View File

@@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
from http import HTTPStatus from http import HTTPStatus
from math import floor
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
@@ -26,7 +27,7 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None: async def on_invoice_paid(payment: Payment) -> None:
# (avoid loops) # (avoid loops)
if "scrubed" == payment.extra.get("tag"): if payment.extra.get("tag") == "scrubed":
# already scrubbed # already scrubbed
return return
@@ -42,12 +43,13 @@ async def on_invoice_paid(payment: Payment) -> None:
# I REALLY HATE THIS DUPLICATION OF CODE!! CORE/VIEWS/API.PY, LINE 267 # I REALLY HATE THIS DUPLICATION OF CODE!! CORE/VIEWS/API.PY, LINE 267
domain = urlparse(data["callback"]).netloc domain = urlparse(data["callback"]).netloc
rounded_amount = floor(payment.amount / 1000) * 1000
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
r = await client.get( r = await client.get(
data["callback"], data["callback"],
params={"amount": payment.amount}, params={"amount": rounded_amount},
timeout=40, timeout=40,
) )
if r.is_error: if r.is_error:
@@ -66,7 +68,8 @@ async def on_invoice_paid(payment: Payment) -> None:
) )
invoice = bolt11.decode(params["pr"]) invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != payment.amount:
if invoice.amount_msat != rounded_amount:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} returned an invalid invoice. Expected {payment.amount} msat, got {invoice.amount_msat}.", detail=f"{domain} returned an invalid invoice. Expected {payment.amount} msat, got {invoice.amount_msat}.",

View File

@@ -28,6 +28,10 @@ async def on_invoice_paid(payment: Payment) -> None:
# now we make some special internal transfers (from no one to the receiver) # now we make some special internal transfers (from no one to the receiver)
targets = await get_targets(payment.wallet_id) targets = await get_targets(payment.wallet_id)
if not targets:
return
transfers = [ transfers = [
(target.wallet, int(target.percent * payment.amount / 100)) (target.wallet, int(target.percent * payment.amount / 100))
for target in targets for target in targets
@@ -41,9 +45,6 @@ async def on_invoice_paid(payment: Payment) -> None:
) )
return return
if not targets:
return
# mark the original payment with one extra key, "splitted" # mark the original payment with one extra key, "splitted"
# (this prevents us from doing this process again and it's informative) # (this prevents us from doing this process again and it's informative)
# and reduce it by the amount we're going to send to the producer # and reduce it by the amount we're going to send to the producer
@@ -76,5 +77,5 @@ async def on_invoice_paid(payment: Payment) -> None:
) )
# manually send this for now # manually send this for now
await internal_invoice_queue.put(internal_checking_id) await internal_invoice_queue.put(internal_checking_id)
return return

View File

@@ -25,7 +25,7 @@ async def m001_initial(db):
name TEXT NOT NULL, name TEXT NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
cur_code TEXT NOT NULL, cur_code TEXT NOT NULL,
sats INT NOT NULL, sats {db.big_int} NOT NULL,
amount FLOAT NOT NULL, amount FLOAT NOT NULL,
service INTEGER NOT NULL, service INTEGER NOT NULL,
posted BOOLEAN NOT NULL, posted BOOLEAN NOT NULL,

View File

@@ -19,8 +19,8 @@ async def m001_initial(db):
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
sats INT NOT NULL, sats {db.big_int} NOT NULL,
tipjar INT NOT NULL, tipjar {db.big_int} NOT NULL,
FOREIGN KEY(tipjar) REFERENCES {db.references_schema}TipJars(id) FOREIGN KEY(tipjar) REFERENCES {db.references_schema}TipJars(id)
); );
""" """

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -1,5 +1,6 @@
# flake8: noqa # flake8: noqa
from .cliche import ClicheWallet from .cliche import ClicheWallet
from .cln import CoreLightningWallet # legacy .env support from .cln import CoreLightningWallet # legacy .env support
from .cln import CoreLightningWallet as CLightningWallet from .cln import CoreLightningWallet as CLightningWallet
@@ -9,6 +10,7 @@ from .lnbits import LNbitsWallet
from .lndgrpc import LndWallet from .lndgrpc import LndWallet
from .lndrest import LndRestWallet from .lndrest import LndRestWallet
from .lnpay import LNPayWallet from .lnpay import LNPayWallet
from .lntips import LnTipsWallet
from .lntxbot import LntxbotWallet from .lntxbot import LntxbotWallet
from .opennode import OpenNodeWallet from .opennode import OpenNodeWallet
from .spark import SparkWallet from .spark import SparkWallet

170
lnbits/wallets/lntips.py Normal file
View File

@@ -0,0 +1,170 @@
import asyncio
import hashlib
import json
import time
from os import getenv
from typing import AsyncGenerator, Dict, Optional
import httpx
from loguru import logger
from .base import (
InvoiceResponse,
PaymentResponse,
PaymentStatus,
StatusResponse,
Wallet,
)
class LnTipsWallet(Wallet):
def __init__(self):
endpoint = getenv("LNTIPS_API_ENDPOINT")
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
key = (
getenv("LNTIPS_API_KEY")
or getenv("LNTIPS_ADMIN_KEY")
or getenv("LNTIPS_INVOICE_KEY")
)
self.auth = {"Authorization": f"Basic {key}"}
async def status(self) -> StatusResponse:
async with httpx.AsyncClient() as client:
r = await client.get(
f"{self.endpoint}/api/v1/balance", headers=self.auth, timeout=40
)
try:
data = r.json()
except:
return StatusResponse(
f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0
)
if data.get("error"):
return StatusResponse(data["error"], 0)
return StatusResponse(None, data["balance"] * 1000)
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
data: Dict = {"amount": amount}
if description_hash:
data["description_hash"] = description_hash.hex()
elif unhashed_description:
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
else:
data["memo"] = memo or ""
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.endpoint}/api/v1/createinvoice",
headers=self.auth,
json=data,
timeout=40,
)
if r.is_error:
try:
data = r.json()
error_message = data["message"]
except:
error_message = r.text
pass
return InvoiceResponse(False, None, None, error_message)
data = r.json()
return InvoiceResponse(
True, data["payment_hash"], data["payment_request"], None
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.endpoint}/api/v1/payinvoice",
headers=self.auth,
json={"pay_req": bolt11},
timeout=None,
)
if r.is_error:
return PaymentResponse(False, None, 0, None, r.text)
if "error" in r.json():
try:
data = r.json()
error_message = data["error"]
except:
error_message = r.text
pass
return PaymentResponse(False, None, 0, None, error_message)
data = r.json()["details"]
checking_id = data["payment_hash"]
fee_msat = -data["fee"]
preimage = data["preimage"]
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.endpoint}/api/v1/invoicestatus/{checking_id}",
headers=self.auth,
)
if r.is_error or len(r.text) == 0:
return PaymentStatus(None)
data = r.json()
return PaymentStatus(data["paid"])
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client:
r = await client.post(
url=f"{self.endpoint}/api/v1/paymentstatus/{checking_id}",
headers=self.auth,
)
if r.is_error:
return PaymentStatus(None)
data = r.json()
paid_to_status = {False: None, True: True}
return PaymentStatus(paid_to_status[data.get("paid")])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
last_connected = None
while True:
url = f"{self.endpoint}/api/v1/invoicestream"
try:
async with httpx.AsyncClient(timeout=None, headers=self.auth) as client:
last_connected = time.time()
async with client.stream("GET", url) as r:
async for line in r.aiter_lines():
try:
prefix = "data: "
if not line.startswith(prefix):
continue
data = line[len(prefix) :] # sse parsing
inv = json.loads(data)
if not inv.get("payment_hash"):
continue
except:
continue
yield inv["payment_hash"]
except Exception as e:
pass
# do not sleep if the connection was active for more than 10s
# since the backend is expected to drop the connection after 90s
if last_connected is None or time.time() - last_connected < 10:
logger.error(
f"lost connection to {self.endpoint}/api/v1/invoicestream, retrying in 5 seconds"
)
await asyncio.sleep(5)