This commit is contained in:
Riccardo Balbo 2024-07-16 16:20:26 +00:00
commit 299007e990
29 changed files with 2887 additions and 0 deletions

252
.devcontainer/.env Normal file
View file

@ -0,0 +1,252 @@
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
######################################
########### Admin Settings ###########
######################################
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
# The rest of the settings will be stored in your database and you will be able to change them
# only through the Admin UI.
# Disable this to make LNbits use this config file again.
LNBITS_ADMIN_UI=true
# Change theme
LNBITS_SITE_TITLE="LNBits_NWC_SP"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
HOST=0.0.0.0
PORT=5000
######################################
########## Funding Source ############
######################################
# which fundingsources are allowed in the admin ui
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet"
LNBITS_BACKEND_WALLET_CLASS=FakeWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file.
# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
# FUNDING_SOURCE_MAX_RETRIES=4
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
LIGHTNING_INVOICE_EXPIRY=3600
# Set one of these blocks depending on the wallet kind you chose above:
# ClicheWallet
CLICHE_ENDPOINT=ws://127.0.0.1:12000
# SparkWallet
SPARK_URL=http://localhost:9737/rpc
SPARK_TOKEN=myaccesstoken
# CoreLightningWallet
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
# CoreLightningRestWallet
CORELIGHTNING_REST_URL=http://127.0.0.1:8185/
CORELIGHTNING_REST_MACAROON="/path/to/clnrest/access.macaroon" # or BASE64/HEXSTRING
CORELIGHTNING_REST_CERT="/path/to/clnrest/tls.cert"
# LnbitsWallet
LNBITS_ENDPOINT=https://demo.lnbits.com
LNBITS_KEY=LNBITS_ADMIN_KEY
# LndWallet
LND_GRPC_ENDPOINT=127.0.0.1
LND_GRPC_PORT=10009
LND_GRPC_CERT="/home/bob/.lnd/tls.cert"
LND_GRPC_MACAROON="/home/bob/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" # or HEXSTRING
# To use an AES-encrypted macaroon, set
# LND_GRPC_MACAROON="eNcRyPtEdMaCaRoOn"
# LndRestWallet
LND_REST_ENDPOINT=https://127.0.0.1:8080/
LND_REST_CERT="/home/bob/.lnd/tls.cert"
LND_REST_MACAROON="/home/bob/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" # or HEXSTRING
# To use an AES-encrypted macaroon, set
# LND_REST_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
# LNPayWallet
LNPAY_API_ENDPOINT=https://api.lnpay.co/v1/
# Secret API Key under developers tab
LNPAY_API_KEY=LNPAY_API_KEY
# Wallet Admin in Wallet Access Keys
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
# AlbyWallet
ALBY_API_ENDPOINT=https://api.getalby.com/
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN
# PhoenixdWallet
PHOENIXD_API_ENDPOINT=http://localhost:9740/
PHOENIXD_API_PASSWORD=PHOENIXD_KEY
# OpenNodeWallet
OPENNODE_API_ENDPOINT=https://api.opennode.com/
OPENNODE_KEY=OPENNODE_ADMIN_KEY
# FakeWallet
FAKE_WALLET_SECRET="ToTheMoon1"
LNBITS_DENOMINATION=sats
# EclairWallet
ECLAIR_URL=http://127.0.0.1:8283
ECLAIR_PASS=eclairpw
# LnTipsWallet
# Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips
######################################
####### Auth Configurations ##########
######################################
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
AUTH_SECRET_KEY="secret"
AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password"
# Set this flag if HTTP is used for OAuth
# OAUTHLIB_INSECURE_TRANSPORT="1"
# Google OAuth Config
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
# GitHub OAuth Config
# Make sure that the authorization callback URL is set to https://{domain}/api/v1/auth/github/token
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# Keycloak OAuth Config
# Make sure that the valid redirect URIs contain https://{domain}/api/v1/auth/keycloak/token
KEYCLOAK_CLIENT_ID=""
KEYCLOAK_CLIENT_SECRET=""
KEYCLOAK_DISCOVERY_URL=""
######################################
# uvicorn variable, uncomment to allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
# FORWARDED_ALLOW_IPS="*"
# Server security, rate limiting ips, blocked ips, allowed ips
LNBITS_RATE_LIMIT_NO="200"
LNBITS_RATE_LIMIT_UNIT="minute"
LNBITS_ALLOWED_IPS=""
LNBITS_BLOCKED_IPS=""
# Allow users and admins by user IDs (comma separated list)
# if set new users will not be able to create accounts
LNBITS_ALLOWED_USERS=""
LNBITS_ADMIN_USERS=""
# ID of the super user. The user ID must exist.
# SUPER_USER=""
# Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
# Start LNbits core only. The extensions are not loaded.
# LNBITS_EXTENSIONS_DEACTIVATE_ALL=true
# Disable account creation for new users
# LNBITS_ALLOW_NEW_ACCOUNTS=false
# Enable Node Management without activating the LNBITS Admin GUI
# by setting the following variables to true.
LNBITS_NODE_UI=false
LNBITS_PUBLIC_NODE_UI=false
# Enabling the transactions tab can cause crashes on large Core Lightning nodes.
LNBITS_NODE_UI_TRANSACTIONS=false
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
# Ad space description
# LNBITS_AD_SPACE_TITLE="Supported by"
# csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor
# LNBITS_AD_SPACE="https://shop.lnbits.com/;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-light.png;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-dark.png"
# LNBITS_SHOW_HOME_PAGE_ELEMENTS=true # if set to true, the ad space will be displayed on the home page
# LNBITS_CUSTOM_BADGE="USE WITH CAUTION - LNbits wallet is still in BETA"
# LNBITS_CUSTOM_BADGE_COLOR="warning"
# Hides wallet api, extensions can choose to honor
LNBITS_HIDE_API=false
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# Path where extensions will be installed (defaults to `./lnbits/`).
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
# The extension must be removed from this list in order to not be re-installed.
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
# for both PostgreSQL and CockroachDB, you'll need to install
# psycopg2 as an additional dependency
LNBITS_DATA_FOLDER="./data"
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
# the service fee (in percent)
LNBITS_SERVICE_FEE=0.0
# the wallet where fees go to
# LNBITS_SERVICE_FEE_WALLET=
# the maximum fee per transaction (in satoshis)
# LNBITS_SERVICE_FEE_MAX=1000
# disable fees for internal transactions
# LNBITS_SERVICE_FEE_IGNORE_INTERNAL=true
# value in millisats
LNBITS_RESERVE_FEE_MIN=2000
# value in percent
LNBITS_RESERVE_FEE_PERCENT=1.0
# limit the maximum balance for each wallet
# throw an error if the wallet attempts to create a new invoice
# LNBITS_WALLET_LIMIT_MAX_BALANCE=1000000
# LNBITS_WALLET_LIMIT_DAILY_MAX_WITHDRAW=1000000
# LNBITS_WALLET_LIMIT_SECS_BETWEEN_TRANS=60
# Limit fiat currencies allowed to see in UI
# LNBITS_ALLOWED_CURRENCIES="EUR, USD"
######################################
###### Logging and Development #######
######################################
DEBUG=false
DEBUG_DATABASE=false
BUNDLE_ASSETS=true
# logging into LNBITS_DATA_FOLDER/logs/
ENABLE_LOG_TO_FILE=true
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90

View file

@ -0,0 +1,26 @@
{
"name": "lnbits_nwc_provider",
"image": "mcr.microsoft.com/devcontainers/python:1-3.9-bullseye",
"features": {
"ghcr.io/devcontainers-contrib/features/poetry:2": {
}
},
"mounts": [
"source=${localWorkspaceFolder}/.devcontainer/start.sh,target=/start-lnbits.sh,type=bind",
"source=${localWorkspaceFolder}/.devcontainer/setup.sh,target=/setup.sh,type=bind"
],
"postCreateCommand": "/bin/bash /setup.sh ${containerWorkspaceFolder}",
"postStartCommand": "/bin/bash /start-lnbits.sh",
"forwardPorts": [
5000
],
"customizations": {
"vscode": {
"settings": {
"python.pythonPath": "/opt/python/bin/python3.9"
}
}
}
}

23
.devcontainer/setup.sh Normal file
View file

@ -0,0 +1,23 @@
#!/bin/bash
CONTAINER_WORKSPACE_FOLDER=$1
sudo apt update -y
sudo apt install -y python3.9-distutils curl
curl -fsSL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh
sudo bash /tmp/nodesource_setup.sh
sudo apt-get install -y nodejs
cd ..
if [ ! -d lnbits ] ; then
sudo git clone https://github.com/lnbits/lnbits.git;
fi
sudo chown 1000:1000 -Rvf lnbits
cd lnbits
git checkout 0.12.8
poetry env use python3.9
VENV_PATH=$(poetry env info -p)
sudo ln -s $VENV_PATH /opt/python
make bundle
poetry install --no-interaction
mkdir -p lnbits/extensions/
ln -s $CONTAINER_WORKSPACE_FOLDER lnbits/extensions/nwcprovider

4
.devcontainer/start.sh Normal file
View file

@ -0,0 +1,4 @@
#!/bin/bash
ln -s $PWD/.devcontainer/.env ../lnbits/.env
cd ../lnbits
poetry run lnbits

53
.github/xworkflows/main.yml vendored Normal file
View file

@ -0,0 +1,53 @@
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Create github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes
pullrequest:
needs: [release]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
token: ${{ secrets.EXT_GITHUB }}
repository: lnbits/lnbits-extensions
path: './lnbits-extensions'
- name: setup git user
run: |
git config --global user.name "alan"
git config --global user.email "alan@lnbits.com"
- name: Create pull request in extensions repo
env:
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
repo_name: "${{ github.event.repository.name }}"
tag: "${{ github.ref_name }}"
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}"
body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}"
archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip"
run: |
cd lnbits-extensions
git checkout -b $branch
# if there is another open PR
git pull origin $branch || echo "branch does not exist"
sh util.sh update_extension $repo_name $tag
git add -A
git commit -am "$title"
git push origin $branch
# check if pr exists before creating it
gh config set pager cat
check=$(gh pr list -H $branch | wc -l)
test $check -ne 0 || gh pr create --title "$title" --body "$body" --repo lnbits/lnbits-extensions

53
.github/xworkflows/release.yml vendored Normal file
View file

@ -0,0 +1,53 @@
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Create github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes
pullrequest:
needs: [release]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
token: ${{ secrets.EXT_GITHUB }}
repository: lnbits/lnbits-extensions
path: './lnbits-extensions'
- name: setup git user
run: |
git config --global user.name "alan"
git config --global user.email "alan@lnbits.com"
- name: Create pull request in extensions repo
env:
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
repo_name: "${{ github.event.repository.name }}"
tag: "${{ github.ref_name }}"
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}"
body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}"
archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip"
run: |
cd lnbits-extensions
git checkout -b $branch
# if there is another open PR
git pull origin $branch || echo "branch does not exist"
sh util.sh update_extension $repo_name $tag
git add -A
git commit -am "$title"
git push origin $branch
# check if pr exists before creating it
gh config set pager cat
check=$(gh pr list -H $branch | wc -l)
test $check -ne 0 || gh pr create --title "$title" --body "$body" --repo lnbits/lnbits-extensions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
__pycache__

54
LICENSE Normal file
View file

@ -0,0 +1,54 @@
> Use any license you like, its your extension.
---
# DON'T BE A DICK PUBLIC LICENSE
> Version 1.1, December 2016
> Copyright (C) 2024 Alan Bits
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document.
> DON'T BE A DICK PUBLIC LICENSE
> TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
1. Do whatever you like with the original work, just don't be a dick.
Being a dick includes - but is not limited to - the following instances:
1a. Outright copyright infringement - Don't just copy this and change the name.
1b. Selling the unmodified original with no work done what-so-ever, that's REALLY being a dick.
1c. Modifying the original work to contain hidden harmful content. That would make you a PROPER dick.
2. If you become rich through modifications, related works/services, or supporting the original work,
share the love. Only a dick would make loads off this work and not buy the original work's
creator(s) a pint.
3. Code is provided with no warranty. Using somebody else's code and bitching when it goes wrong makes
you a DONKEY dick. Fix the problem yourself. A non-dick would submit the fix back.
---
# MIT License
> Copyright (c) 2024 Alan Bits
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

497
NWCServiceProvider.py Normal file
View file

@ -0,0 +1,497 @@
import asyncio
import hashlib
import json
from typing import Dict
import secp256k1
from loguru import logger
from lnbits.settings import settings
import time
import websockets
from Cryptodome import Random
from Cryptodome.Cipher import AES
import base64
import random
from typing import Union, List, Callable, Tuple
from lnbits.app import settings
from lnbits.helpers import encrypt_internal_message
from urllib.parse import quote
class MainSubscription:
def __init__(self):
self.requests_sub_id = None
self.responses_sub_id = None
self.requests_eose = False
self.responses_eose = False
self.events:Dict[str, Dict] = {}
self.responses:List[str] = []
def getStale(self) -> List[Dict]:
pending_events = []
for [id, event] in self.events.items():
if not id in self.responses:
pending_events.append(event)
return pending_events
def registerResponse(self, event_id:str):
if not event_id in self.responses:
self.responses.append(event_id)
class NWCServiceProvider:
def __init__(self, private_key:str=None, relay:str=None):
if not relay: # Connect to nostrclient
relay = "nostrclient"
if relay == "nostrclient":
relay=f"ws://localhost:{settings.port}/nostrclient/api/v1/relay"
elif relay == "nostrclient:private":
relay_endpoint = encrypt_internal_message("relay")
relay=f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}"
self.relay = relay
if not private_key: # Create random key
private_key = bytes.hex(secp256k1._gen_private_key())
self.private_key = secp256k1.PrivateKey(bytes.fromhex(private_key))
self.private_key_hex = private_key
self.public_key = self.private_key.pubkey
self.public_key_hex = self.public_key.serialize().hex()[2:]
self.supported_methods = []
self.subscriptions_count = 0
self.request_listeners = {}
self.reconnect_task = None
self.sub = None
# websocket connection
self.ws = None
# if True the websocket is connected
self.connected = False
# if True the wallet is shutting down
self.shutdown = False
logger.info("NWC Service is ready. relay: "+str(self.relay)+" pubkey: " +
self.public_key_hex)
def getSupportedMethods(self):
return self.supported_methods
def addRequestListener(self, method: str, l: Callable[["NWCConnector", str, Dict], List[Tuple[Dict, Dict]]]):
if not method in self.supported_methods:
self.supported_methods.append(method)
self.request_listeners[method] = l
async def start(self):
"""
Starts the NWC service connection.
"""
self.reconnect_task = asyncio.create_task(self._connect_to_relay())
def _json_dumps(self, data: Union[Dict, list]) -> str:
"""
Converts a Python dictionary to a JSON string with compact encoding.
Args:
data (Dict): The dictionary to be converted.
Returns:
str: The compact JSON string.
"""
if isinstance(data, Dict):
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(',', ':'), ensure_ascii=False)
def _is_shutting_down(self) -> bool:
"""
Returns True if the wallet is shutting down.
"""
return self.shutdown or not settings.lnbits_running
async def _send(self, data: Dict):
"""
Sends data to the NWC relay.
Args:
data (Dict): The data to be sent.
"""
if self._is_shutting_down():
logger.warning("Trying to send data while shutting down")
return
await self._wait_for_connection() # ensure the connection is established
tx = self._json_dumps(data)
await self.ws.send(tx)
def _get_new_subid(self) -> str:
"""
Generates a unique subscription id.
Returns:
str: The generated 64 characters long subscription id (eg. lnbits0abc...)
"""
subid = "lnbitsnwcs"+str(self.subscriptions_count)
self.subscriptions_count += 1
maxLength = 64
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
n = maxLength - len(subid)
if n > 0:
for i in range(n):
subid += chars[random.randint(0, len(chars) - 1)]
return subid
async def _wait_for_connection(self):
"""
Waits until the wallet is connected to the relay.
"""
while not self.connected:
if self._is_shutting_down():
raise Exception("Connection is closing")
logger.debug("Waiting for connection...")
await asyncio.sleep(1)
async def _subscribe(self):
"""
[Re]Subscribe to receive nip 47 requests and responses from the relay
"""
self.sub = MainSubscription()
# Create requests subscription
req_filter = {
"kinds": [23194],
"#p": [self.public_key_hex],
# Since the last 3 hours (handles reboots)
"since": int(time.time()) - 3*60*60
}
self.sub.requests_sub_id = self._get_new_subid()
# Create responses subscription (needed to track previosly responded requests)
res_filter = {
"kinds": [23195],
"authors": [self.public_key_hex],
"since": int(time.time()) - 3*60*60
}
self.sub.responses_sub_id = self._get_new_subid()
# Subscribe
await self._send(["REQ", self.sub.requests_sub_id, req_filter])
await self._send(["REQ", self.sub.responses_sub_id, res_filter])
async def _on_connection(self,ws):
"""
On connection callback, announce the service provider methods and subscribe to nip67 events.
"""
# Send info event
event = {
"kind": 13194,
"content": " ".join(self.supported_methods),
"created_at": int(time.time()),
"tags": [
["p", self.public_key_hex]
]
}
self._sign_event(event)
await self._send(["EVENT", event])
# Resubscribe to nwc events
await self._subscribe()
async def _handle_request(self, event):
"""
Handle a nwc request
"""
nwc_pubkey = event["pubkey"]
content = event["content"]
# Decrypt the content
content = self._decrypt_content(content, nwc_pubkey)
# Deserialize content
content = json.loads(content)
# Handle request
method = content["method"]
l = self.request_listeners.get(method, None)
outs = []
if not l:
outs.append({
"error": {
"code": "NOT_IMPLEMENTED",
"message": "Method "+method+" is not implemented by this service provider"
}
})
else:
try:
results = await l(self, nwc_pubkey, content)
for result in results:
r = result[0]
e = result[1]
t = result[2] if len(result) > 2 else None
out = {}
if r: out["result"] = r
if e: out["error"] = e
if t: out["tags"] = t
outs.append(out)
except Exception as e:
outs.append({
"code": "INTERNAL",
"message": str(e)
})
for out in outs:
# Finalize output
out["result_type"] = method
# Prepare response event
res = {
"kind": 23195,
"created_at": int(time.time()),
"tags": out.get("tags", []),
"content": self._json_dumps(out),
}
# Reference request
res["tags"].append(["e", event["id"]])
# Reference user
res["tags"].append(["p", nwc_pubkey])
# Finalize response event
res["content"] = self._encrypt_content(res["content"], nwc_pubkey)
self._sign_event(res)
# Register response for this request, so we knows it is not stale
self.sub.registerResponse(event["id"])
# Send response event
await self._send(["EVENT", res])
async def _on_message(self, ws, message: str):
"""
Handle incoming messages from the relay.
"""
try:
msg = json.loads(message)
if msg[0] == "EVENT": # Event message
sub_id = msg[1]
event = msg[2]
# Ensure the event is valid (do not trust relays)
if not self._verify_event(event):
raise Exception("Invalid event signature")
tags = event["tags"]
expiration = -1
for tag in tags:
if tag[0] == "expiration":
expiration = int(tag[1])
break
# Handle event expiration if the relay doesn't support nip 40
if expiration > 0 and expiration < int(time.time()):
logger.debug("Event expired")
return
if event["kind"] == 23194 and sub_id == self.sub.requests_sub_id:
# Ensure the request is for this service provider
valid_p = False
for tag in tags:
if tag[0] == "p" and tag[1] == self.public_key_hex:
valid_p = True
break
if not valid_p:
raise Exception("Unexpected request from another service")
# Track request
self.sub.events[event["id"]] = event
# if eose was received for both subscriptions, we handle the request in realtime
# if not, we do nothing since the request may be already handled or stale,
# all stale requests will be handled later when eose is received
if self.sub.requests_eose and self.sub.responses_eose:
await self._handle_request(event)
elif event["kind"] == 23195 and sub_id == self.sub.responses_sub_id:
# Ensure the response is from this service provider
if event["pubkey"] != self.public_key_hex:
raise Exception("Unexpected response from another service")
# Register as response for each e tag (request event id)
# Note: usually we expect only one "e" tag, but we are handling multiple "e" tags just in case
for tag in tags:
if tag[0] == "e":
self.sub.registerResponse(tag[1])
elif msg[0] == "EOSE":
sub_id = msg[1]
# Track EOSE
if sub_id == self.sub.requests_sub_id:
self.sub.requests_eose = True
elif sub_id == self.sub.responses_sub_id:
self.sub.responses_eose = True
# When both EOSE are receives, handle all the stale requests
# Note: All the requests that were received prior to the service connection
# and do not have a response yet, are considered stale, we will process them now
if self.sub.requests_eose and self.sub.responses_eose:
stales = self.sub.getStale()
for stale in stales:
await self._handle_request(stale)
elif msg[0] == "CLOSED":
# Subscription was closed remotely.
sub_id = msg[1]
info = msg[2] or "" if len(msg) > 2 else ""
# Resubscribe if one of the main subscriptions was closed
if sub_id == self.sub.requests_sub_id or sub_id == self.sub.responses_sub_id:
logger.warning("Subscription "+sub_id+" was closed remotely: "+info+" ... resubscribing...")
self._subscribe()
elif msg[0] == "NOTICE":
# A message from the relay, mostly useless, but we log it anyway
logger.info("Notice from relay "+self.relay+": "+str(msg[1]))
elif msg[0] == "OK":
pass
else:
raise Exception("Unknown message type")
except Exception as e:
logger.error("Error parsing event: "+str(e))
async def _connect_to_relay(self):
"""
Initiate websocket connection to the relay.
"""
await asyncio.sleep(1)
logger.debug("Connecting to NWC relay "+self.relay)
while not self._is_shutting_down(): # Reconnect until the wallet is shutting down
logger.debug('Creating new connection...')
try:
async with websockets.connect(self.relay) as ws:
self.ws = ws
self.connected = True
await self._on_connection(ws)
while not self._is_shutting_down(): # receive messages until the wallet is shutting down
try:
reply = await ws.recv()
await self._on_message(ws, reply)
except Exception as e:
logger.debug("Error receiving message: " + str(e))
break
logger.debug("Connection to NWC relay closed")
except Exception as e:
logger.error("Error connecting to NWC relay: "+str(e))
await asyncio.sleep(5)
# the connection was closed, so we set the connected flag to False
# this will make the methods calling _wait_for_connection() to wait until the connection is re-established
self.connected = False
if not self._is_shutting_down():
# Wait some time before reconnecting
logger.debug("Reconnecting to NWC relay in 5 seconds...")
await asyncio.sleep(5)
def _encrypt_content(self, content: str, pubkey_hex:str) -> str:
"""
Encrypts the content to be sent to the service.
Args:
content (str): The content to be encrypted.
Returns:
str: The encrypted content.
"""
pubkey = secp256k1.PublicKey(
bytes.fromhex("02" + pubkey_hex), True)
shared = pubkey.tweak_mul(bytes.fromhex(
self.private_key_hex)).serialize()[1:]
# random iv (16B)
iv = Random.new().read(AES.block_size)
aes = AES.new(shared, AES.MODE_CBC, iv)
# padding
def pad(s): return s + (16 - len(s) % 16) * chr(16 - len(s) % 16)
content = pad(content).encode("utf-8")
# Encrypt
encryptedB64 = base64.b64encode(aes.encrypt(content)).decode("ascii")
ivB64 = base64.b64encode(iv).decode("ascii")
encryptedContent = encryptedB64 + "?iv=" + ivB64
return encryptedContent
def _decrypt_content(self, content: str , pubkey_hex:str) -> str:
"""
Decrypts the content coming from the service.
Args:
content (str): The encrypted content.
Returns:
str: The decrypted content.
"""
pubkey = secp256k1.PublicKey(
bytes.fromhex("02" + pubkey_hex), True)
shared = pubkey.tweak_mul(bytes.fromhex(
self.private_key_hex)).serialize()[1:]
# extract iv and content
(encryptedContentB64, ivB64) = content.split("?iv=")
encryptedContent = base64.b64decode(
encryptedContentB64.encode("ascii"))
iv = base64.b64decode(ivB64.encode("ascii"))
# Decrypt
aes = AES.new(shared, AES.MODE_CBC, iv)
decrypted = aes.decrypt(encryptedContent).decode("utf-8")
def unpad(s): return s[:-ord(s[len(s)-1:])]
return unpad(decrypted)
def _verify_event(self, event: Dict) -> bool:
"""
Signs the event (in place) with the service secret
Args:
event (Dict): The event to be signed.
Returns:
Dict: The input event with the signature added.
"""
signature_data = self._json_dumps([
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"]
])
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
if event_id != event["id"]: # Invalid event id
return False
pubkeyHex = event["pubkey"]
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkeyHex), True)
if not pubkey.schnorr_verify(bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True):
return False
return True
def _sign_event(self, event: Dict) -> Dict:
"""
Signs the event (in place) with the service secret
Args:
event (Dict): The event to be signed.
Returns:
Dict: The input event with the signature added.
"""
signature_data = self._json_dumps([
0,
self.public_key_hex,
event["created_at"],
event["kind"],
event["tags"],
event["content"]
])
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id
event["pubkey"] = self.public_key_hex
signature = (self.private_key.schnorr_sign(
bytes.fromhex(event_id), None, raw=True)).hex()
event["sig"] = signature
return event
async def cleanup(self):
logger.debug("Closing NWC Service Provider connection")
self.shutdown = True # Mark for shutdown
# close tasks
try:
if self.reconnect_task:
self.reconnect_task.cancel()
except Exception as e:
logger.warning("Error closing reconnection task: "+str(e))
# close the websocket
try:
if self.ws:
await self.ws.close()
except Exception as e:
logger.warning("Error closing websocket connection: "+str(e))

20
README.md Normal file
View file

@ -0,0 +1,20 @@
`The README.md typically serves as a guide for using the extension.`
# NWCService - An [LNbits](https://github.com/lnbits/lnbits) Extension
## A Starter Template for Your Own Extension
Ready to start hacking? Once you've forked this extension, you can incorporate functions from other extensions as needed.
### How to Use This Template
> This guide assumes you're using this extension as a base for a new one, and have installed LNbits using https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md#option-1-recommended-poetry.
1. Install and enable the extension either through the official LNbits manifest or by adding https://raw.githubusercontent.com/lnbits/nwcservice/main/manifest.json to `"Server"/"Server"/"Extension Sources"`. ![Extension Sources](https://i.imgur.com/MUGwAU3.png) ![image](https://github.com/lnbits/nwcservice/assets/33088785/4133123b-c747-4458-ba6c-5cc7c0f124d8)
2. `Ctrl c` shut down your LNbits installation.
3. Download the extension files from https://github.com/lnbits/nwcservice to a folder outside of `/lnbits`, and initialize the folder with `git`. Alternatively, create a repo, copy the nwcservice extension files into it, then `git clone` the extension to a location outside of `/lnbits`.
4. Remove the installed extension from `lnbits/lnbits/extensions`.
5. Create a symbolic link using `ln -s /home/ben/Projects/<name of your extension> /home/ben/Projects/lnbits/lnbits/extensions`.
6. Restart your LNbits installation. You can now modify your extension and `git push` changes to a repo.
7. When you're ready to share your manifest so others can install it, edit `/lnbits/nwcservice/manifest.json` to include the git credentials of your extension.
8. IMPORTANT: If you want your extension to be added to the official LNbits manifest, please follow the guidelines here: https://github.com/lnbits/lnbits-extensions#important

58
__init__.py Normal file
View file

@ -0,0 +1,58 @@
import asyncio
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import create_permanent_unique_task
from loguru import logger
db = Database("ext_nwcprovider")
execution_queue = asyncio.Queue()
scheduled_tasks: list[asyncio.Task] = []
nwcprovider_ext: APIRouter = APIRouter(
prefix="/nwcprovider", tags=["NWC Service Provider"]
)
nwcprovider_static_files = [
{
"path": "/nwcprovider/static",
"name": "nwcprovider",
}
]
def nwcprovider_renderer():
return template_renderer(["nwcprovider/templates"])
async def enqueue(action):
future = asyncio.Future()
execution_queue.put_nowait({
"action": action,
"future": future
})
return await future
from .views import *
from .views_api import *
from .tasks import handle_nwc, handle_execution_queue
def nwcprovider_stop():
for task in scheduled_tasks:
try:
task.cancel()
except Exception as ex:
logger.warning(ex)
def nwcprovider_start():
task = create_permanent_unique_task("ext_nwcprovider", handle_nwc)
scheduled_tasks.append(task)
task = create_permanent_unique_task(
"ext_nwcprovider_execution_queue", handle_execution_queue)
scheduled_tasks.append(task)

27
config.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "NWC Service Provider",
"short_description": "A NWC service provider for lnbits",
"tile": "/nwcprovider/static/image/nwcprovider.png",
"min_lnbits_version": "0.12.5",
"contributors": [
{
"name": "Riccardo Balbo",
"uri": "https://github.com/riccardobl",
"role": "Dev"
}
],
"images": [
{
"uri": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/static/image/1.png"
},
{
"uri": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/static/image/2.png"
},
{
"uri": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/static/image/3.png"
}
],
"description_md": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/description.md",
"terms_and_conditions_md": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/toc.md",
"license": "MIT"
}

196
crud.py Normal file
View file

@ -0,0 +1,196 @@
from typing import Optional, Any, List
from . import db
from .models import NWCKey, NWCBudget, NWCLog, NWCNewBudget
import time
from . import enqueue
import json
async def create_nwc(
pubkey: str,
wallet_id:str,
description: str,
expires_at: int ,
permissions:List[str] ,
budgets: Optional[List[NWCNewBudget]] = None
):
# Check if the key already exists
if await get_nwc(pubkey, None, True):
raise Exception("Public key already used")
now=int(time.time())
await db.execute(
"""
INSERT INTO nwcprovider.keys (pubkey, wallet, description, permissions, created_at, expires_at, last_used)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(pubkey, wallet_id, description, " ".join(permissions), now,int(expires_at) if expires_at else 0, now)
)
if budgets:
for budget in budgets:
await db.execute(
"""
INSERT INTO nwcprovider.budgets (pubkey, budget_msats, refresh_window, created_at)
VALUES (?, ?, ?, ?)
""",
(pubkey, budget.budget_msats, budget.refresh_window, budget.created_at)
)
return NWCKey(
pubkey=pubkey,
wallet=wallet_id,
description=description,
expires_at=expires_at,
permissions=" ".join(permissions),
created_at=now,
last_used=now
)
async def delete_nwc(
pubkey: str,
wallet_id:str
):
nwc = await get_nwc(pubkey, wallet_id)
if not nwc:
raise Exception("Public key does not exist")
await db.execute(
"""
DELETE FROM nwcprovider.keys WHERE pubkey = ? AND wallet = ?
""",
(pubkey, wallet_id)
)
async def get_wallet_nwcs(
wallet_id: str,
include_expired: Optional[bool] = False
) -> List[NWCKey]:
rows = await db.fetchall(
"SELECT * FROM nwcprovider.keys WHERE wallet = ? AND (expires_at = 0 OR expires_at > ?)", (wallet_id, int(time.time()) if not include_expired else -1 )
)
return [NWCKey(**row) for row in rows]
async def get_nwc(
pubkey: str,
wallet_id: Optional[str] = None,
include_expired: Optional[bool] = False,
refresh_last_used: Optional[bool] = False
) -> Optional[NWCKey]:
# expires_at = 0 means it never expires
if wallet_id:
row = await db.fetchone(
"SELECT * FROM nwcprovider.keys WHERE pubkey = ? AND wallet = ? AND (expires_at = 0 OR expires_at > ?)", (pubkey, wallet_id, int(time.time()) if not include_expired else -1 )
)
else:
row = await db.fetchone(
"SELECT * FROM nwcprovider.keys WHERE pubkey = ? AND (expires_at = 0 OR expires_at > ?)", (pubkey, int(time.time()) if not include_expired else -1 )
)
if not row:
return None
if refresh_last_used:
await db.execute(
"""
UPDATE nwcprovider.keys SET last_used = ? WHERE pubkey = ?
""",
(int(time.time()), pubkey)
)
return NWCKey(**row)
async def get_budgets_nwc(pubkey, calculate_spent=False):
rows = await db.fetchall(
"SELECT * FROM nwcprovider.budgets WHERE pubkey = ?", (pubkey)
)
budgets = [NWCBudget(**row) for row in rows]
if calculate_spent:
for budget in budgets:
last_cycle, next_cycle = budget.get_timestamp_range()
tot_spent_in_range_msats = await db.fetchone(
"""
SELECT SUM(amount_msats) FROM nwcprovider.spent WHERE pubkey = ? AND created_at >= ? AND created_at < ?
""",
(pubkey, last_cycle, next_cycle)
)
tot_spent_in_range_msats = tot_spent_in_range_msats[0] or 0
budget.used_budget_msats = tot_spent_in_range_msats
return budgets
async def log_nwc(
pubkey: str,
payload:Optional[Any] = None
):
if not payload: payload=""
payload = json.dumps(payload)
await db.execute(
"""
INSERT INTO nwcprovider.logs (pubkey, payload, created_at)
VALUES (?, ?, ?)
""",
(pubkey, payload, int(time.time()))
)
async def tracked_spend_nwc(
pubkey: str,
amount_msats: int,
action
):
async def r():
created_at = int(time.time())
budgets = await get_budgets_nwc(pubkey)
in_budget = True
for budget in budgets:
last_cycle, next_cycle = budget.get_timestamp_range()
tot_spent_in_range_msats = await db.fetchone(
"""
SELECT SUM(amount_msats) FROM nwcprovider.spent WHERE pubkey = ? AND created_at >= ? AND created_at < ?
""",
(pubkey, last_cycle, next_cycle)
)[0] or 0 # Ensure we get an int, default to 0 if None
if tot_spent_in_range_msats + amount_msats > budget.budget_msats:
in_budget = False
break
if not in_budget:
return False, None
out = await action()
await db.execute(
"""
INSERT INTO nwcprovider.spent (pubkey, amount_msats, created_at)
VALUES (?, ?, ?)
""",
(pubkey, amount_msats, created_at)
)
return True, out
return await enqueue(r)
async def get_config_nwc(key:str):
row = await db.fetchone(
"SELECT * FROM nwcprovider.config WHERE key = ?", (key)
)
if not row:
return None
return row["value"]
async def get_all_config_nwc():
rows = await db.fetchall(
"SELECT * FROM nwcprovider.config"
)
return {row["key"]:row["value"] for row in rows}
async def set_config_nwc(key:str, value:str):
await db.execute(
"""
DELETE FROM nwcprovider.config
WHERE key = ?
""",
(key,)
)
await db.execute(
"""
INSERT INTO nwcprovider.config (key, value)
VALUES (?, ?)
""",
(key, value)
)

10
description.md Normal file
View file

@ -0,0 +1,10 @@
NWCService can be used as a template for building new extensions, it includes a bunch of functions that can be edited/deleted as you need them.
This is a longform description that will be used in the advanced description when users click on the "more" button on the extension cards.
Adding some bullets is nice covering:
* Functionality
* Use cases
...and some other text about just how great this etension is.

9
manifest.json Normal file
View file

@ -0,0 +1,9 @@
{
"repos": [
{
"id": "nwcprovider",
"organisation": "riccardobl",
"repository": "nwcprovider"
}
]
}

112
migrations.py Normal file
View file

@ -0,0 +1,112 @@
import secp256k1
async def m001_initial(db):
"""
Initial tables
"""
await db.execute(
"""
CREATE TABLE nwcprovider.keys (
pubkey TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
description TEXT NOT NULL,
expires_at INTEGER NOT NULL,
permissions TEXT NOT NULL,
created_at INTEGER NOT NULL
);
"""
)
await db.execute(
"""
CREATE TABLE nwcprovider.spent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pubkey TEXT NOT NULL,
amount_msats INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(pubkey) REFERENCES keys(pubkey) ON DELETE CASCADE
);
"""
)
await db.execute(
"""
CREATE TABLE nwcprovider.logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pubkey TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(pubkey) REFERENCES keys(pubkey) ON DELETE CASCADE
);
"""
)
await db.execute(
"""
CREATE TABLE nwcprovider.budgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pubkey TEXT NOT NULL,
budget_msats INTEGER NOT NULL,
refresh_window INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(pubkey) REFERENCES keys(pubkey) ON DELETE CASCADE
);
"""
)
async def m002_config(db):
"""
Config table
"""
await db.execute(
"""
CREATE TABLE nwcprovider.config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
)
async def m003_default_config(db):
"""
Default config
"""
await db.execute(
"""
INSERT INTO nwcprovider.config (key, value) VALUES ('relay', 'nostrclient');
"""
)
new_private_key = bytes.hex(secp256k1._gen_private_key())
await db.execute(
"""
INSERT INTO nwcprovider.config (key, value) VALUES ('provider_key', ?);
""",
(new_private_key,)
)
async def m004_default_config2(db):
"""
Default config
"""
await db.execute(
"""
INSERT INTO nwcprovider.config (key, value) VALUES ('relay_alias', ?);
""",
('',)
)
async def m005_key_last_used(db):
"""
Add last_used to keys
"""
await db.execute(
"""
ALTER TABLE nwcprovider.keys ADD COLUMN last_used INTEGER;
"""
)

88
models.py Normal file
View file

@ -0,0 +1,88 @@
# Data models for your extension
from sqlite3 import Row
from pydantic import BaseModel, Field
import time
import json
from typing import List, Dict, Any
from pydantic import BaseModel
class NWCKey(BaseModel):
pubkey: str
wallet: str
description: str
expires_at: int
permissions: str
created_at: int
last_used: int
def getPermissions(cls) -> List[str]:
try:
return cls.permissions.split(" ")
except:
# TODO: log error
return []
@classmethod
def from_row(cls, row: Dict[str, Any]) -> "NWCKey":
return cls(**row)
class NWCBudget(BaseModel):
id: int
pubkey: str
budget_msats: int
refresh_window: int
created_at: int
used_budget_msats: int = 0
def get_timestamp_range(cls):
c = int(time.time())
if cls.refresh_window <= 0: # never refresh
# return a timestamp in the future
return c + 21000000
# calculate the next refresh timestamp
elapsed = c - cls.created_at
passed_cycles = elapsed // cls.refresh_window
last_cycle = cls.created_at + (passed_cycles * cls.refresh_window)
next_cycle = last_cycle + cls.refresh_window
return last_cycle, next_cycle
@classmethod
def from_row(cls, row: Row) -> "NWCBudget":
return cls(**dict(row))
class NWCLog(BaseModel):
id: int
pubkey: str
payload: str
created_at: int
@classmethod
def from_row(cls, row: Row) -> "NWCLog":
return cls(**dict(row))
class NWCNewBudget(BaseModel):
budget_msats: int
refresh_window: int
created_at: int
class NWCRegistrationRequest(BaseModel):
permissions: List[str]
description: str
expires_at: int
budgets: List[NWCNewBudget]
class NWCGetResponse(BaseModel):
data: NWCKey
budgets: List[NWCBudget]

49
permission.py Normal file
View file

@ -0,0 +1,49 @@
nwc_permissions = {
"pay":{
"name":"Send payments",
"methods":[
"multi_pay_invoice",
"pay_invoice",
"pay_keysend",
"multi_pay_keysend",
],
"default": True
},
"invoice":{
"name":"Create invoices",
"methods":[
"make_invoice"
],
"default": True
},
"lookup":{
"name":"Lookup status of invoice",
"methods":[
"lookup_invoice"
],
"default": True
},
"history": {
"name": "Read transaction history",
"methods": [
"list_transactions"
],
"default": True
},
"balance": {
"name": "Read wallet balance",
"methods": [
"get_balance"
],
"default": True
},
"info": {
"name": "Read account info",
"methods": [
"get_info"
],
"default": True
}
}

BIN
static/image/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
static/image/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
static/image/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

2
static/js/noble-secp256k1.min.js vendored Normal file

File diff suppressed because one or more lines are too long

376
tasks.py Normal file
View file

@ -0,0 +1,376 @@
import asyncio
from loguru import logger
from lnbits.core.models import Payment
from lnbits.core.services import create_invoice, pay_invoice, check_transaction_status, PaymentError
from bolt11 import decode as bolt11_decode
from lnbits.core.crud import get_wallet_payment, get_payments, get_wallet
from .crud import get_nwc, tracked_spend_nwc, log_nwc, get_config_nwc
from . import execution_queue
from .NWCServiceProvider import NWCServiceProvider
from typing import Dict, List, Tuple
from math import ceil
import time
from lnbits.db import Filters
from .models import NWCKey
from typing import Optional
from .permission import nwc_permissions
from lnbits.settings import settings
async def _check(nwc: Optional[NWCKey], method: str, payload: Dict):
# check
if not nwc:
return {
"code": "UNAUTHORIZED",
"message": "This public key has no wallet connected."
}
# check permissions
allowed = False
permissions = nwc.getPermissions()
for p in permissions:
allowed_methods = nwc_permissions.get(p, {}).get("methods", [])
if method in allowed_methods:
allowed = True
break
if not allowed:
return {
"code": "RESTRICTED",
"message": "This public key is not allowed to do this operation."
}
return None
async def _process_invoice(wallet_id:str, pubkey:str, invoice:str, amount_msats:int, description:Optional[str]=None):
async def execute_payment():
return await pay_invoice(wallet_id=wallet_id, payment_request=invoice,
max_sat=int(ceil(amount_msats/1000)),
description=description or ""
)
payment_hash = None
try:
in_budget, payment_hash = await tracked_spend_nwc(pubkey, amount_msats, execute_payment)
if not in_budget:
error = {
"code":"QUOTA_EXCEEDED",
"message": "The wallet has exceeded its spending quota."
}
return {
"error": error,
"in_budget": False
}
except PaymentError as e:
status = e.status
message = e.message
if status == "failed":
error = {
"code": "PAYMENT_FAILED",
"message": message
}
return {
"error": error,
"in_budget": False
}
else:
raise e
if not payment_hash:
raise Exception("Payment hash not found")
wait_for_preimage = True # currently required by nip 47 specs, might change in future
payment_status = None
while wait_for_preimage:
payment_status = await check_transaction_status(wallet_id, payment_hash)
if payment_status.success:
break
await asyncio.sleep(0.05)
return {
"preimage": payment_status.preimage or "0000000000000000000000000000000000000000000000000000000000000000",
"fee_msats": payment_status.fee_msat,
"paid": payment_status.paid,
"payment_hash": payment_hash,
"in_budget": in_budget
}
async def _on_pay_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "pay_invoice", payload)
if error:
return [(None,error, [])]
params = payload.get("params", {})
invoice = params.get("invoice", None)
# Ensures invoice is provided
if not invoice:
raise Exception("Missing invoice")
invoice_data = bolt11_decode(invoice)
amount_msats = invoice_data.amount_msat
res = await _process_invoice(nwc.wallet, pubkey, invoice, amount_msats, invoice_data.description)
error = res.get("error")
if error:
return [(None, error, [])]
preimage = res.get("preimage")
out = {
"preimage": preimage,
}
await log_nwc(pubkey, payload)
return [(out,None, [])]
async def _on_multi_pay_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "multi_pay_invoice", payload)
if error:
return [(None, error, [])]
params = payload.get("params", {})
invoices = params.get("invoices", [])
results = []
# Ensures all invoices are provided
for i in invoices:
invoice = i.get("invoice",None)
if not invoice:
raise Exception("Missing invoice")
for i in invoices:
try:
id = i.get("id", None)
invoice = i.get("invoice", None)
invoice_data = bolt11_decode(invoice)
amount_msats = invoice_data.amount_msat
res = await _process_invoice(nwc.wallet, pubkey, invoice, amount_msats, invoice_data.description)
error = res.get("error")
if error:
results.append((None, error, []))
else:
r = (
{
"preimage": res.get("preimage"),
},
None,
{
"d": id if id else res.get("payment_hash"),
})
results.append(r)
except Exception as e:
results.append((None, {
"code": "INTERNAL",
"message": str(e)
}))
await log_nwc(pubkey, payload)
return results
async def _on_make_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "make_invoice", payload)
if error:
return [(None, error, [])]
params = payload.get("params", {})
amount_msats = params.get("amount", None)
# Ensures amount is provided
if not amount_msats:
raise Exception("Missing amount")
description = params.get("description", "")
description_hash = params.get("description_hash", None)
expiry = params.get("expiry" , None)
payment_hash, payment_request = await create_invoice(
wallet_id=nwc.wallet,
amount=int(amount_msats/1000),
currency="sat",
memo=description,
description_hash=bytes.fromhex(description_hash) if description_hash else None,
unhashed_description=description.encode("utf-8"),
expiry=expiry)
payment_status = await check_transaction_status(wallet_id=nwc.wallet, payment_hash=payment_hash)
preimage = payment_status.preimage
res = {
"type": "incoming",
"invoice": payment_request,
"description": description,
"description_hash": description_hash,
"preimage": preimage,
"payment_hash": payment_hash,
"amount": amount_msats,
#"fees_paid":None,
"created_at": int(time.time()),
"metadata": {}
}
if expiry:
res["expires_at"] = int(time.time()) + int(expiry)
await log_nwc(pubkey, payload)
return [(res, None, [])]
async def _on_lookup_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "lookup_invoice", payload)
if error:
return [(None, error, [])]
params = payload.get("params", {})
payment_hash = params.get("payment_hash", None)
invoice = params.get("invoice", None)
# Ensure payment_hash or invoice are provided
if not payment_hash and not invoice:
raise Exception("Missing payment_hash or invoice")
# Extract hash from invoice if not provided
if not payment_hash:
invoice_data = bolt11_decode(invoice)
payment_hash = invoice_data.payment_hash
# Get payment data
payment = await get_wallet_payment(nwc.wallet, payment_hash)
if not payment:
raise Exception("Payment not found")
invoice_data = bolt11_decode(payment.bolt11)
is_settled = not payment.pending
res = {
"type": "outgoing" if payment.is_out else "incoming",
"invoice": payment.bolt11,
"description": invoice_data.description if invoice_data.description else payment.memo,
"preimage": payment.preimage if is_settled or payment.is_in else None,
"payment_hash": payment.payment_hash,
"amount": abs(payment.msat),
"fees_paid": abs(payment.fee),
"created_at": payment.time,
"expires_at": payment.time+payment.expiry,
"settled_at": payment.time if is_settled else None,
"metadata": {}
}
if invoice_data.description_hash:
res["description_hash"] = invoice_data.description_hash
await log_nwc(pubkey, payload)
return [(res, None, [])]
async def _on_list_transactions(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "list_transactions", payload)
if error:
return [(None, error, [])]
tfrom = payload.get("from", 0)
tto = payload.get("to", int(time.time()))
limit = payload.get("limit", 10)
offset = payload.get("offset", 0)
unpaid = payload.get("unpaid", False)
type = payload.get("type", None)
values = []
filters = Filters()
filters.where("time <= ?")
values.append(tto)
filters.values(values)
history = await get_payments(
wallet_id=nwc.wallet,
complete=True,
pending = unpaid,
outgoing=not type or type == "outgoing",
incoming =not type or type=="incoming",
since=tfrom,
exclude_uncheckable=False,
filters=filters,
limit=limit,
offset=offset
)
transactions = []
for p in history:
p: Payment
invoice_data = bolt11_decode(p.bolt11)
is_settled = not p.pending
transactions.append({
"type": "outgoing" if p.is_out else "incoming",
"invoice": p.bolt11,
"description": invoice_data.description,
"description_hash": invoice_data.description_hash,
"preimage": p.preimage if is_settled or p.is_in else None,
"payment_hash": p.payment_hash,
"amount": abs(p.msat),
"fees_paid": p.fee,
"created_at": p.time,
"settled_at": p.time if is_settled else None,
"metadata": {}
})
await log_nwc(pubkey, payload)
return [({
"transactions": transactions
}, None, [])]
async def _on_get_balance(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "get_balance", payload)
if error:
return [(None, error, [])]
balance = 0
wallet = await get_wallet(nwc.wallet)
if not wallet:
raise Exception("Wallet not found")
balance = wallet.balance_msat
await log_nwc(pubkey, payload)
return [({
"balance": balance
}, None, [])]
async def _on_get_info(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "get_info", payload)
if error:
return [(None, error, [])]
sp_methods = sp.getSupportedMethods()
permissions = nwc.getPermissions()
account_methods = []
for spm in sp_methods:
for p in permissions:
allowed_methods = nwc_permissions.get(p, {}).get("methods", [])
if spm in allowed_methods:
account_methods.append(spm)
break
await log_nwc(pubkey, payload)
return [({
"alias": settings.lnbits_site_title,
"color": "",
"network": "mainnet",
"block_height": 0,
"block_hash": "",
"methods": account_methods
}, None, [])]
async def handle_nwc():
priv_key = await get_config_nwc("provider_key")
relay = await get_config_nwc("relay")
nwcsp = NWCServiceProvider(priv_key, relay)
nwcsp.addRequestListener("pay_invoice", _on_pay_invoice)
nwcsp.addRequestListener("multi_pay_invoice", _on_multi_pay_invoice)
nwcsp.addRequestListener("make_invoice", _on_make_invoice)
nwcsp.addRequestListener("lookup_invoice", _on_lookup_invoice)
nwcsp.addRequestListener("list_transactions", _on_list_transactions)
nwcsp.addRequestListener("get_balance", _on_get_balance)
nwcsp.addRequestListener("get_info", _on_get_info)
# currently not supported by lnbits
# nwcsp.addRequestListener("pay_keysend", _on_pay_keysend)
# nwcsp.addRequestListener("multi_pay_keysend", _on_multi_pay_keysend)
###
await nwcsp.start()
try:
while True:
await asyncio.sleep(3600)
except asyncio.CancelledError:
await nwcsp.cleanup()
raise
async def handle_execution_queue():
while True:
try:
task = await execution_queue.get()
action = task.get("action")
future = task.get("future")
try:
res = await action()
future.set_result(res)
except Exception as e:
future.set_exception(e)
except Exception as e:
logger.error(str(e))

View file

@ -0,0 +1,103 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md" id="configTable">
<div class="col-12 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">NWC Service Provider - Config</h5>
</div>
</div>
<template>
<q-markup-table flat>
<tbody>
<q-tr v-for="entry in entries" :key="entry.key">
<q-td>
<span>
${entry.key}
</span>
</q-td>
<q-td>
<q-input v-model="entry.value" />
</q-td>
</q-tr>
</tbody>
</q-markup-table>
</template>
<q-btn @click="saveConfig" unelevated label="Save" color="primary" class="q-mt-md full-width" />
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
delimiters: ['${', '}'],
data: function () {
return {
entries: [],
columns: [
{ name: 'key', required: true, label: 'Key', align: 'left', field: row => row.key, sortable: true },
{ name: 'value', required: true, label: 'Value', align: 'left', field: row => row.value, sortable: true },
]
}
},
methods: {
fetchConfig() {
this.entries = [];
LNbits.api
.request('GET', '/nwcprovider/api/v1/config', this.g.user.wallets[0].adminkey)
.then((response) => {
const newEntries = [];
for (const [key, value] of Object.entries(response.data)) {
newEntries.push({
key: key,
value: value,
});
}
this.entries = newEntries;
})
.catch(function (error) {
console.error("Error fetching config:", error);
});
},
async saveConfig() {
const data = {};
for (const entry of this.entries) {
data[entry.key] = entry.value;
}
try {
const response = await LNbits.api
.request('POST', '/nwcprovider/api/v1/config', this.g.user.wallets[0].adminkey, data)
this.$q.notify({
message: 'Config saved, please restart the server',
color: 'positive',
position: 'top',
timeout: 2000,
actions: [{ icon: 'close', color: 'white' }]
})
} catch (error) {
console.error("Error saving config:", error);
}
}
},
created: function () {
this.fetchConfig()
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,659 @@
<!--/////////////////////////////////////////////////-->
<!--//PAGE FOR THE EXTENSIONS BACKEND IN LNBITS//////-->
<!--/////////////////////////////////////////////////-->
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<!-- <div class="q-gutter-x-md row items-center"> -->
<!-- <div class="col"> -->
<q-select v-model="selectedWallet" filled dense emit-value :options="g.user.walletOptions"
label="Wallet *"></q-select>
<!-- </div>
<div class="col">
<q-btn unelevated color="primary" icon="add" @click="formDialog.show = true">Connect app</q-btn>
</div> -->
<!-- </div> -->
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col items-center">
<h5 class="text-subtitle1">Connected Apps</h5>
</div>
<q-btn @click="showConnectDialog()" aria-label="Connect" icon="add" color="primary" size="sm" round />
</div>
<q-table dense flat :data="nwcEntries" row-key="id" :columns="nwcsTable.columns"
:pagination.sync="nwcsTable.pagination">
<nwcprovider v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
${ col.label }
</q-th>
</q-tr>
</nwcprovider>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div>${ col.value }</div>
</q-td>
<q-td>
<q-btn flat dense size="xs" @click="openConnectionInfoDialog(props.row)" icon="info" color="primary">
<q-tooltip> Info </q-tooltip>
</q-btn>
<q-btn flat dense size="xs" @click="deleteNWC(props.row.pubkey)" icon="cancel" color="pink">
<q-tooltip> Delete </q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} NWC Service provider extension</h6>
<p>
An extension that turns LNBits into a Nostr Wallet Connect service provider.
</p>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
<q-item clickable @click="go('../docs#/NWC%20Service%20Provider')"
class="q-flex items-center justify-between">
<q-icon name="swap_vertical_circle" class="q-mr-md"></q-icon>
<q-item-section>Swagger API</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item v-if="this.g.user.admin" clickable @click="go('/nwcprovider/admin')"
class="q-flex items-center justify-between">
<q-icon name="settings" class="q-mr-md"></q-icon>
<q-item-section>Settings</q-item-section>
</q-item>
<!-- <q-expansion-item v-if="this.g.user.admin" group="extras" icon="admin" label="Admin">
<q-card>
<q-card-section>
<p>
Manage wallets using NWC
</p>
<small>Created by
<a class="text-secondary" href="https://github.com/riccardobl" target="_blank">Riccardo Balbo</a>.</small>
<small>Repo
<a class="text-secondary" href="https://github.com/riccardobl/nwcprovider" target="_blank">NWC Service
Providers</a>.</small>
</q-card-section>
</q-card>
</q-expansion-item> -->
</q-list>
</q-card-section>
</q-card>
</div>
<!--/////////////////////////////////////////////////-->
<!--//////////////FORM DIALOG////////////////////////-->
<!--/////////////////////////////////////////////////-->
<q-dialog v-model="connectionInfoDialog.show" @hide="closeConnectionInfoDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-card-section class="q-ma-none">
<h3 class="q-ma-none q-mb-md text-center">Info</h3>
<q-list>
<q-item>
<q-item-section>Description</q-item-section>
<q-item-section side>${connectionInfoDialog.data.description}</q-item-section>
</q-item>
<q-item>
<q-item-section>Last used</q-item-section>
<q-item-section side>${connectionInfoDialog.data.last_used}</q-item-section>
</q-item>
<q-item>
<q-item-section>Expires</q-item-section>
<q-item-section side>${connectionInfoDialog.data.expires_at}</q-item-section>
</q-item>
<q-item>
<q-item-section>Created</q-item-section>
<q-item-section side>${connectionInfoDialog.data.created_at}</q-item-section>
</q-item>
<q-item>
<q-item-section>Permissions</q-item-section>
<q-item-section side>
${connectionInfoDialog.data.permissions}
</q-item-section>
</q-item>
<q-item>
<q-item-section>Limits
<q-markup-table class="q-mt-md">
<thead>
<q-tr>
<q-th>
Budget (sats)
<div style="font-size: smaller;">used/max</div>
</q-th>
<q-th>Refresh</q-th>
</q-tr>
</thead>
<tbody>
<q-tr v-if="!connectionInfoDialog.data.budgets || connectionInfoDialog.data.budgets.length === 0">
<q-td colspan="2" class="text-center">No limits</q-td>
</q-tr>
<q-tr v-else v-for="budget in connectionInfoDialog.data.budgets">
<q-td class="text-center">${budget.used_budget_sats} / ${budget.budget_sats}</q-td>
<q-td class="text-center">${budget.refresh_window}</q-td>
</q-tr>
</tbody>
</q-markup-table>
</q-item-section>
</q-item>
</q-list>
<q-card-actions class="q-mt-md">
<q-btn v-close-popup flat color="grey" class="q-ml-auto full-width " @click="closePairingDialog">Close</q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="pairingQrDialog.show" @hide="closePairingQrDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-card-section class="q-ma-none">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<lnbits-qrcode :value="pairingQrDialog.data.pairingUrl"></lnbits-qrcode>
</q-responsive>
</q-card-section>
<q-card-section class="q-ma-none">
<p class=" q-ma-none text-center">Please scan this QR code with a supported app</p>
<p class=" q-ma-none text-center text-caption ">Connect only with app you trust!</p>
</q-card-section>
<q-card-actions>
<q-btn label="Close" flat color="grey" class="full-width
" @click="closePairingQrDialog"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="pairingDialog.show" @hide="closePairingDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-card-section class="q-ma-none">
<h3 class="q-ma-none q-mb-md text-center">Pairing</h3>
<p class=" q-ma-none text-center">Complete the last step of the setup by pasting or scanning your connection's
pairing secret in the desired
app to
finalise the connection.</p>
<p class=" q-ma-none text-center text-caption ">Connect only with app you trust!</p>
</q-card-section>
<q-card-actions>
<q-btn label="Open in Supported App" color="primary" class="full-width" icon="launch"
@click="openPairingUrl()"></q-btn>
</q-card-actions>
<q-card-section class="q-ma-none">
<h5 class="q-ma-none text-center">Advanced</h5>
</q-card-section>
<q-card-actions>
<q-btn label="Copy Pairing URL" flat color="grey" class="full-width" icon="content_copy"
@click="copyPairingUrl()"></q-btn>
</q-card-actions>
<q-card-actions>
<q-btn label="Show QR Code" flat color="grey" class="full-width" icon="qr_code_scanner"
@click="showPairingQR()"></q-btn>
</q-card-actions>
<q-card-actions>
<q-btn v-close-popup flat color="grey" class="q-ml-auto full-width" @click="closePairingDialog">Close</q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="connectDialog.show" @hide="closeConnectDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<h3 class="q-ma-none q-mb-xl text-center">Add connection</h3>
<q-form @submit="confirmConnectDialog" class="q-gutter-md">
<q-input v-model="connectDialog.data.description" filled dense label="Description"></q-input>
<div class="row">
<q-input :disable="connectDialog.data.neverExpires" v-model="connectDialog.data.expires_at" class="col"
v-model="connectDialog.data.expires_at" filled dense label="Expires at" type="datetime-local"></q-input>
<q-checkbox v-model="connectDialog.data.neverExpires" class="col-auto"
v-model="connectDialog.data.neverExpires" label="Never"></q-checkbox>
</div>
<q-list dense bordered padding>
<q-item>
<q-item-label header>Authorize the app to</q-item-label>
</q-item>
<q-item v-for="permission in connectDialog.data.permissions">
<q-checkbox v-model="permission.value" :label="permission.name"></q-checkbox>
</q-item>
</q-list>
<q-list dense bordered padding>
<q-item>
<q-item-label header>Limit the spendable amount</q-item-label>
</q-item>
<q-item v-if="!connectDialog.data.budgets || connectDialog.data.budgets.length === 0">
<q-item-section class="full-width text-center">No limit</q-item-section>
</q-item>
<template v-else>
<q-item v-for="(budget,index) in connectDialog.data.budgets">
<q-item-section>
<q-input filled dense type="number" v-model="budget.budget_sats" label="Limit (sats)"></q-input>
</q-item-section>
<q-item-section>
<q-select filled dense emit-value :options="[
{label: 'Daily', value: 'Daily'},
{label: 'Weekly', value: 'Weekly'},
{label: 'Monthly', value: 'Monthly'},
{label: 'Yearly', value: 'Yearly'},
{label: 'Never', value: 'Never'}
]" label="Expires" v-model="budget.expiry"></q-select>
</q-item-section>
<q-item-section side>
<q-btn flat icon="delete" @click="deleteBudget(index)"></q-btn>
</q-item-section>
</q-item>
</template>
<q-item>
<q-btn flat icon="add" class="full-width" label="Add limit" @click="addBudget"></q-btn>
</q-item>
</q-list>
<q-card-actions class="q-gutter-sm">
<div class="col q-mr-md">
<q-btn unelevated color="primary" type="submit" class="full-width">Connect</q-btn>
</div>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</q-card-actions>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script type="module">
import * as NobleSecp256k1 from '/nwcprovider/static/js/noble-secp256k1.min.js';
window.NobleSecp256k1 = NobleSecp256k1;
</script>
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
delimiters: ['${', '}'],
data: function () {
return {
selectedWallet: null,
nodePermissions: [],
nwcEntries: [],
nwcsTable: {
columns: [
{ name: 'description', align: 'left', label: 'Description', field: 'description' },
{ name: 'status', align: 'left', label: 'Status', field: 'status' },
{ name: 'last_used', align: 'left', label: 'Last used', field: 'last_used' },
{ name: 'created_at', align: 'left', label: 'Created', field: 'created_at' },
{ name: 'expires_at', align: 'left', label: 'Expires', field: 'expires_at' }
],
pagination: {
rowsPerPage: 10
}
},
connectDialog: {
show: false,
data: {},
},
pairingDialog: {
show: false,
data: {
pairingUrl: ''
}
},
pairingQrDialog: {
show: false,
data: {
pairingUrl: ''
}
},
connectionInfoDialog: {
show: false,
data: {}
},
}
},
methods: {
showConnectDialog() {
const wallet = this.getWallet();
if (!wallet) {
this.$q.notify({ color: 'red', message: 'Please select a wallet first' });
return;
} else {
this.connectDialog.show = true;
}
},
openConnectionInfoDialog(data) {
this.connectionInfoDialog.data = data;
this.connectionInfoDialog.show = true;
},
closeConnectionInfoDialog() {
this.connectionInfoDialog.show = false;
},
openPairingUrl() {
const url = this.pairingDialog.data.pairingUrl;
if (url) window.open(url, '_blank');
},
go(url) {
window.open(url, '_blank');
},
async copyPairingUrl() {
const url = this.pairingDialog.data.pairingUrl;
if (url) {
try {
await navigator.clipboard.writeText(url);
this.$q.notify({ color: 'primary', message: 'URL copied to clipboard!' });
} catch (err) {
this.$q.notify({ color: 'red', message: 'Failed to copy URL.' });
}
}
},
showPairingQR() {
this.pairingQrDialog.data.pairingUrl = this.pairingDialog.data.pairingUrl;
this.pairingQrDialog.show = true;
},
closePairingQrDialog() {
this.pairingQrDialog.show = false;
},
loadConnectDialogData() {
this.connectDialog.data = {
description: '',
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 7,
neverExpires: true,
permissions: [],
budgets: []
};
for (const permission of this.nodePermissions) {
this.connectDialog.data.permissions.push({
key: permission.key,
name: permission.name,
value: permission.value
});
}
},
deleteBudget(index) {
this.connectDialog.data.budgets.splice(index, 1)
},
addBudget() {
this.connectDialog.data.budgets.push({
budget_sats: 1000,
used_budget_sats: 0,
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
expiration: 'never'
})
},
closeConnectDialog() {
this.connectDialog.show = false
this.loadConnectDialogData();
},
getWallet: function () {
let wallet = undefined;
for (let i = 0; i < this.g.user.wallets.length; i++) {
if (this.g.user.wallets[i].id == this.selectedWallet) {
wallet = this.g.user.wallets[i];
break;
}
}
return wallet;
},
async generateKeyPair() {
while (!window.NobleSecp256k1) {
await new Promise(resolve => setTimeout(resolve, 1));
}
const privKeyBytes = window.NobleSecp256k1.utils.randomPrivateKey();
const pubKeyBytes = window.NobleSecp256k1.getPublicKey(privKeyBytes);
const out = {
privKeyBytes: privKeyBytes,
pubKeyBytes: pubKeyBytes,
privKey: window.NobleSecp256k1.etc.bytesToHex(privKeyBytes),
pubKey: window.NobleSecp256k1.etc.bytesToHex(pubKeyBytes.slice(1))
};
return out;
},
deleteNWC: async function (pubkey) {
this.$q.dialog({
title: 'Confirm Deletion',
message: 'Are you sure you want to delete this connection?',
cancel: true,
persistent: true
}).onOk(async () => {
try {
const wallet = this.getWallet();
const response = await LNbits.api.request('DELETE', `/nwcprovider/api/v1/nwc/${pubkey}`, wallet.adminkey);
this.loadNwcs();
this.$q.notify({ type: 'positive', message: 'Deleted successfully' });
} catch (error) {
LNbits.utils.notifyApiError(error);
}
}).onCancel(() => {
// User canceled the operation
});
},
loadNwcs: async function () {
const wallet = this.getWallet();
if (!wallet) {
this.nwcs = [];
return;
}
try {
const response = await LNbits.api
.request(
'GET',
'/nwcprovider/api/v1/nwc?includeExpired=true&calculateSpendBudget=true',
wallet.adminkey
)
this.nwcs = response.data;
} catch (error) {
this.nwcs = [];
}
try {
const response = await LNbits.api.request(
'GET',
'/nwcprovider/api/v1/permissions',
wallet.adminkey
)
const permissions = [];
for (const [key, value] of Object.entries(response.data)) {
permissions.push({
key: key,
name: value.name,
value: value.default
});
}
this.nodePermissions = permissions;
} catch (error) {
Lnbits.utils.notifyApiError(error);
}
this.loadConnectDialogData();
const newTableEntries = [];
for (const nwc of this.nwcs) {
const t = Quasar.utils.date.formatDate(
new Date(nwc.data.created_at * 1000),
'YYYY-MM-DD HH:mm'
);
const e = nwc.data.expires_at > 0 ? Quasar.utils.date.formatDate(
new Date(nwc.data.expires_at * 1000),
'YYYY-MM-DD HH:mm'
) : "Never";
const l = Quasar.utils.date.formatDate(
new Date(nwc.data.last_used * 1000),
'YYYY-MM-DD HH:mm'
);
const nwcTableEntry = {
description: nwc.data.description,
created_at: t,
expires_at: e,
last_used: l,
pubkey: nwc.data.pubkey,
permissions: nwc.data.permissions,
budgets: [],
status: "Active"
}
if(nwc.data.expires_at > 0 && nwc.data.expires_at < new Date().getTime() / 1000){
nwcTableEntry.status = "Expired";
}
for (const budget of nwc.budgets) {
const createdAt = Quasar.utils.date.formatDate(
new Date(budget.created_at * 1000),
'YYYY-MM-DD HH:mm'
);
let refreshWindow = budget.refresh_window;
if (refreshWindow <= 0) {
refreshWindow = 'Never';
} else if (refreshWindow == 60 * 60 * 24) {
refreshWindow = 'Daily';
} else if (refreshWindow == 60 * 60 * 24 * 7) {
refreshWindow = 'Weekly';
} else if (refreshWindow == 60 * 60 * 24 * 30) {
refreshWindow = 'Monthly';
} else if (refreshWindow == 60 * 60 * 24 * 365) {
refreshWindow = 'Yearly';
}
nwcTableEntry.budgets.push({
budget_sats: budget.budget_msats / 1000,
used_budget_sats: budget.used_budget_msats / 1000,
created_at: createdAt,
refresh_window: refreshWindow
});
}
newTableEntries.push(nwcTableEntry);
}
this.nwcEntries = newTableEntries;
},
closePairingDialog() {
this.pairingDialog.show = false;
},
async showPairingDialog(secret) {
let response = await LNbits.api.request(
'GET',
'/nwcprovider/api/v1/pairing/{SECRET}'
);
response = response.data;
response = response.replace("{SECRET}", secret);
this.pairingDialog.data.pairingUrl = response;
this.pairingDialog.show = true;
},
async confirmConnectDialog() {
const keyPair = await this.generateKeyPair();
// timestamp
let expires_at = 0;
if (!this.connectDialog.data.neverExpires) {
expires_at = new Date(this.connectDialog.data.expires_at).getTime() / 1000;
}
const data = {
permissions: [],
description: this.connectDialog.data.description,
expires_at: expires_at,
budgets: []
};
for (const permission of this.connectDialog.data.permissions) {
if (permission.value) data.permissions.push(permission.key);
}
for (const budget of this.connectDialog.data.budgets) {
const budget_msats = budget.budget_sats * 1000;
let refresh_window = 0
switch (budget.expiry) {
case 'Daily':
refresh_window = 60 * 60 * 24;
break;
case 'Weekly':
refresh_window = 60 * 60 * 24 * 7;
break;
case 'Monthly':
refresh_window = 60 * 60 * 24 * 30;
break;
case 'Yearly':
refresh_window = 60 * 60 * 24 * 365;
break;
case 'Never':
refresh_window = 0;
break;
}
data.budgets.push({
budget_msats: budget_msats,
refresh_window: refresh_window,
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
});
}
const wallet = this.getWallet();
try {
const response = await LNbits.api
.request('PUT', '/nwcprovider/api/v1/nwc/' + keyPair.pubKey, wallet.adminkey, data);
this.closeConnectDialog()
if (!response.data || !response.data.data || !response.data.data.pubkey) {
LNbits.utils.notifyApiError("Error creating nwc pairing");
return;
}
this.showPairingDialog(keyPair.privKey);
} catch (error) {
LNbits.utils.notifyApiError(error)
}
this.loadNwcs();
}
},
created: function () {
this.loadNwcs()
},
watch: {
selectedWallet(newValue, oldValue) {
this.loadNwcs();
},
},
})
</script>
{% endblock %}

22
toc.md Normal file
View file

@ -0,0 +1,22 @@
# Terms and Conditions for LNbits Extension
## 1. Acceptance of Terms
By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension.
## 2. License
The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license.
## 3. No Warranty
The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms.
## 4. Limitation of Liability
In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction.
## 5. Modification of Terms
The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension.
## 6. General Provisions
If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension.
## 7. Contact Information
If you have any questions about these Terms, please contact the developer at [developer's contact information].

26
views.py Normal file
View file

@ -0,0 +1,26 @@
from http import HTTPStatus
from fastapi import Depends, Request
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.settings import settings
from lnbits.decorators import check_admin
from . import nwcprovider_ext,nwcprovider_renderer
@nwcprovider_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return nwcprovider_renderer().TemplateResponse(
"nwcprovider/index.html", {"request": request, "user": user.dict()}
)
@nwcprovider_ext.get("/admin", response_class=HTMLResponse)
async def admin(request: Request, user: User = Depends(check_admin)):
return nwcprovider_renderer().TemplateResponse(
"nwcprovider/admin.html", {"request": request, "user": user.dict()}
)

167
views_api.py Normal file
View file

@ -0,0 +1,167 @@
from http import HTTPStatus
import json
from typing import List, Optional, Dict
from .models import NWCKey, NWCBudget,NWCRegistrationRequest , NWCNewBudget,NWCGetResponse
from fastapi import Depends, Request
from loguru import logger
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
require_admin_key
)
from pydantic import BaseModel,Field
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from . import nwcprovider_ext
from .crud import get_nwc, get_wallet_nwcs, get_all_config_nwc, create_nwc, delete_nwc, get_budgets_nwc, get_config_nwc,set_config_nwc
from lnbits.decorators import check_admin
from fastapi import Depends
import secp256k1
from .permission import nwc_permissions
# Get supported permissions
@nwcprovider_ext.get("/api/v1/permissions", status_code=HTTPStatus.OK)
async def api_get_permissions(
req: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Dict:
return nwc_permissions
## Get nwc keys associated with the wallet
@nwcprovider_ext.get("/api/v1/nwc", status_code=HTTPStatus.OK, response_model=List[NWCGetResponse])
async def api_get_nwcs(
req: Request,
includeExpired: Optional[bool] = False,
calculateSpendBudget: Optional[bool] = False,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
wallet_id = wallet.wallet.id
nwcs = await get_wallet_nwcs(wallet_id, includeExpired)
out = []
for nwc in nwcs:
budgets = await get_budgets_nwc(nwc.pubkey, calculateSpendBudget)
res = NWCGetResponse(
data=nwc,
budgets=budgets
)
out.append(res)
return out
# Get a nwc key
@nwcprovider_ext.get("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK, response_model=NWCGetResponse)
async def api_get_nwc(
req: Request,
pubkey: str,
includeExpired: Optional[bool] = False,
wallet: WalletTypeInfo = Depends(require_admin_key)
) -> NWCGetResponse:
wallet_id = wallet.wallet.id
nwc = await get_nwc(pubkey, wallet_id, includeExpired)
res = NWCGetResponse(
data=nwc,
budgets=await get_budgets_nwc(pubkey)
)
return res
# Get pairing url for given secret
@nwcprovider_ext.get("/api/v1/pairing/{secret}", status_code=HTTPStatus.OK, response_model=str)
async def api_get_pairing_url(
req: Request,
secret: str
) -> str:
pprivkey = await get_config_nwc("provider_key")
relay = await get_config_nwc("relay")
relay_alias = await get_config_nwc("relay_alias")
if relay_alias:
relay = relay_alias
else:
if relay == "nostrclient":
scheme = req.url.scheme # http or https
netloc = req.url.netloc # hostname and port
if scheme=="http":
scheme = "ws"
else:
scheme = "wss"
netloc += "/nostrclient/api/v1/relay"
relay = f"{scheme}://{netloc}"
psk = secp256k1.PrivateKey(bytes.fromhex(pprivkey))
ppk = psk.pubkey
ppubkey = ppk.serialize().hex()[2:]
url = "nostr+walletconnect://"
url += ppubkey
url += "?relay="+relay
url += "&secret="+secret
#lud16=?
return url
## Register a new nwc key
@nwcprovider_ext.put("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.CREATED, response_model=NWCGetResponse)
async def api_register_nwc(
req: Request,
pubkey: str,
registration_data: NWCRegistrationRequest, # Use the Pydantic model here
wallet: WalletTypeInfo = Depends(require_admin_key)
):
wallet_id = wallet.wallet.id
nwc = await create_nwc(pubkey, wallet_id, registration_data.description, registration_data.expires_at, registration_data.permissions, registration_data.budgets)
budgets = await get_budgets_nwc(pubkey)
res = NWCGetResponse(
data=nwc,
budgets=budgets
)
return res
# Delete a nwc key
@nwcprovider_ext.delete("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK)
async def api_delete_nwc(
req: Request,
pubkey: str,
wallet: WalletTypeInfo=Depends(require_admin_key)
):
wallet_id = wallet.wallet.id
await delete_nwc(pubkey, wallet_id)
return JSONResponse(content={"message": f"NWC key {pubkey} deleted successfully."})
# Get config
@nwcprovider_ext.get("/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)])
async def api_get_all_config_nwc(
req: Request,
):
config = await get_all_config_nwc()
return config
# Get config
@nwcprovider_ext.get("/api/v1/config/{key}", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)])
async def api_get_config_nwc(
req: Request,
key:str
):
config = await get_config_nwc(key)
out = {}
out[key] = config
return out
# Set config
@nwcprovider_ext.post("/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)])
async def api_set_config_nwc(
req: Request
):
data = await req.json()
for key, value in data.items():
await set_config_nwc(key, value)
return await api_get_all_config_nwc(req)