From fb9ee29f7e5441e3793f05f2e1f6d85dbbd06b31 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 16 Jul 2024 16:20:26 +0000 Subject: [PATCH] first --- .devcontainer/.env | 252 ++++++++++++ .devcontainer/devcontainer.json | 26 ++ .devcontainer/setup.sh | 23 ++ .devcontainer/start.sh | 4 + .github/xworkflows/main.yml | 53 +++ .github/xworkflows/release.yml | 53 +++ .gitignore | 1 + LICENSE | 54 +++ NWCServiceProvider.py | 497 +++++++++++++++++++++++ README.md | 20 + __init__.py | 58 +++ config.json | 27 ++ crud.py | 195 +++++++++ description.md | 10 + manifest.json | 9 + migrations.py | 112 ++++++ models.py | 88 +++++ permission.py | 49 +++ static/image/1.png | Bin 0 -> 13110 bytes static/image/2.png | Bin 0 -> 13110 bytes static/image/3.png | Bin 0 -> 13110 bytes static/image/nwcprovider.png | Bin 0 -> 5211 bytes static/js/noble-secp256k1.min.js | 2 + tasks.py | 376 ++++++++++++++++++ templates/nwcprovider/admin.html | 103 +++++ templates/nwcprovider/index.html | 659 +++++++++++++++++++++++++++++++ toc.md | 22 ++ views.py | 26 ++ views_api.py | 167 ++++++++ 29 files changed, 2886 insertions(+) create mode 100644 .devcontainer/.env create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/setup.sh create mode 100644 .devcontainer/start.sh create mode 100644 .github/xworkflows/main.yml create mode 100644 .github/xworkflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 NWCServiceProvider.py create mode 100644 README.md create mode 100644 __init__.py create mode 100644 config.json create mode 100644 crud.py create mode 100644 description.md create mode 100644 manifest.json create mode 100644 migrations.py create mode 100644 models.py create mode 100644 permission.py create mode 100644 static/image/1.png create mode 100644 static/image/2.png create mode 100644 static/image/3.png create mode 100644 static/image/nwcprovider.png create mode 100644 static/js/noble-secp256k1.min.js create mode 100644 tasks.py create mode 100644 templates/nwcprovider/admin.html create mode 100644 templates/nwcprovider/index.html create mode 100644 toc.md create mode 100644 views.py create mode 100644 views_api.py diff --git a/.devcontainer/.env b/.devcontainer/.env new file mode 100644 index 0000000..eeb6120 --- /dev/null +++ b/.devcontainer/.env @@ -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 ";;, ;;", 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 \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..9aae926 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" + } + } + } +} diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh new file mode 100644 index 0000000..212efcb --- /dev/null +++ b/.devcontainer/setup.sh @@ -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 + + diff --git a/.devcontainer/start.sh b/.devcontainer/start.sh new file mode 100644 index 0000000..124fd53 --- /dev/null +++ b/.devcontainer/start.sh @@ -0,0 +1,4 @@ +#!/bin/bash +ln -s $PWD/.devcontainer/.env ../lnbits/.env +cd ../lnbits +poetry run lnbits diff --git a/.github/xworkflows/main.yml b/.github/xworkflows/main.yml new file mode 100644 index 0000000..f82e72e --- /dev/null +++ b/.github/xworkflows/main.yml @@ -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 diff --git a/.github/xworkflows/release.yml b/.github/xworkflows/release.yml new file mode 100644 index 0000000..f82e72e --- /dev/null +++ b/.github/xworkflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..93b0e4e --- /dev/null +++ b/LICENSE @@ -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. diff --git a/NWCServiceProvider.py b/NWCServiceProvider.py new file mode 100644 index 0000000..69a463f --- /dev/null +++ b/NWCServiceProvider.py @@ -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)) diff --git a/README.md b/README.md new file mode 100644 index 0000000..49c7d37 --- /dev/null +++ b/README.md @@ -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/ /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 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..dfdc095 --- /dev/null +++ b/__init__.py @@ -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) + diff --git a/config.json b/config.json new file mode 100644 index 0000000..81b1aca --- /dev/null +++ b/config.json @@ -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" +} diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..e94e073 --- /dev/null +++ b/crud.py @@ -0,0 +1,195 @@ +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 + ) + +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) + ) + diff --git a/description.md b/description.md new file mode 100644 index 0000000..b5c097e --- /dev/null +++ b/description.md @@ -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. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..6a77c95 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "repos": [ + { + "id": "nwcprovider", + "organisation": "riccardobl", + "repository": "nwcprovider" + } + ] +} diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..a499866 --- /dev/null +++ b/migrations.py @@ -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; + """ + ) diff --git a/models.py b/models.py new file mode 100644 index 0000000..fbae213 --- /dev/null +++ b/models.py @@ -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] + + diff --git a/permission.py b/permission.py new file mode 100644 index 0000000..4110bcb --- /dev/null +++ b/permission.py @@ -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 + } +} \ No newline at end of file diff --git a/static/image/1.png b/static/image/1.png new file mode 100644 index 0000000000000000000000000000000000000000..e0a39323dc5363065111296ca3597ac93cc059f4 GIT binary patch literal 13110 zcmb`tbySpH6fQo9w1|=tf`pd7i!Z`J${SMTk#@4+4P*Wu(PbK_HA+;7<+r z9`Fn7Gj=-&#BnPl{z2U{d3O#^on)c~=a3qYCEtG>%Z}dKjx|hjce&`Xv@~M=Yv)FNiC7OJ2*caXq(g#PH zM)#pT-lx*bo9{ENrU`iLH!yzhUAqrz!sTA61$>|qg<<%Y{QrIsJL`jn5^;!VZ=AM< z3w3!3@bP73WmARy2Hd{*LlSu%?2U|!92^$1FAUCy+Jn{XqNT#&wqckzxqi!`G*;ug|rVq7P}E$*$jXv$TL#6!co|9%0r) zV2Q*&+(q0Z^XvLBM# z-P;v@c(pn*C^XT7U_I`&)T?p!lCkV+52rls7#5 z*cs&Q`L({lN!r?oWJ&Ra-KMaih+5^B8;*PEyNL@M#*YX=Fk|oomx~8zZ-pctyJ~h) z{}=XiwUm{XOEoUY$D+1{cAAm}UK5>CD~0sP2Xpi28;iBDtk_tx{=@`@>rRW4#^gb_ zFQCNCpFfSaqT6vYE*^`%W_vw>b}7q7Ak33QVU*>;X)v8P4(?Xkxi!C0fssS-Z27fX zanN*${a)gMyKPMGntS1H<=*#t)VeN}?R}J4b*Ac8Cf*&KR+t#~K7m-=eJmUoeFl(S z7%rqiw>0R{u=eP4{uko|14SjeO_x%b8rs^FwT^cgop4-wwV-Uq`li$1;7<{>oN-EI(uGiohsy9IAZ9X5QgEps9C^df~w4zobh>`=a~-C#e{~G+ocR)?uMiX{>`#V!sw;UrqT_w;`(J*uDf_(6|M#)ZhIfuEu6FM zZF8T+Y~h06*!f;nM-U~8}oLeAMezgb|<1`UCyL(_`CF5?+U}$fxH!sxRoqMTCidV3r4 z%kJIV4C_2x0`|sZQ4X+y^2w{eKAiQKiGH>Lzt!0faG0)gKyT;I;nMTo)ffHk4x0(g zdtr5_CMuncfGpL%2GMx7cPBh@7GT!xhv|2LF`d_X$ZCg5NUjR*(ed$ryj|-JGn<;4 z+Ae_Ka6Che#ezjYr|df<;~4te>P`)ZbdGUSaN(lNL`+K)>zuW8wO#bdK@YWbbcRC$ zEwKJ=)%Ub)j7}KCP!>QRMG9FtxiM`zL8cF}U%#H7o@^1(2^k8%Gi>s27W-sf*{n~D zS5{GJH&X-k{4CCpbuAbd*>v-6ct-d2e#R8-PR5xS=B*ek!1?h~zS4{5<{Q#Gch*&= zp=B{$S=l=Ud@ixSi0GL-x4+YDv59`J%hr^(d_r$}-bbm8;2`69QgIPlb$tdQqiudq zOh<81ELg|EF~GX3^4k5{QZ^D^4BiOE{)CCb3j3LHF6VBmd39uNx*=WWk_j}&WcK^v zYCjohf$Ae4R#mICpU-?Gv%9BpG?T3FeUF1tHx?Vx8RvJ(3w+O>v$7bs^iJhkbJu=2cL!XO z3NE&vseDRe&l(gmKS8bOBx?)IOo^g`A=PO$UCgQ!>iqP?{_^~$QD$KVf_(!C~R6N|C z{3k0@oY{n{L;fyZ;2gL2^r_l+z~Y5*TMQ)jiw}-4E2%rgtxL>x?(OdCdDoWG<3Sj7mD@tp>KQf-QMmE|4GNFii8YVTIIg_(h1Mk zCUSNs&fR=$ZK5Wi8m3RNBAfA$@LlN5Jq-?yVFCht#WW%P3ZFZgTf-#Dxwc@U2;oS` zk2dx%tGfv!e8alGF1&b_#8&86DQ*-bCB577Vr8#;)J~^oXKt?`NOz5PYwRwd5ip`M zD^OOt~ovKPEw@0~laq2u*8kYHnZ}r|(^x zcNZ*|)iHW$*Am+Ccfq5a?r6$p4K)jLZm+*?UZvpCQ4P`~e|$3ORO~A)$dPVOM4?d3 zF~n!8FZbR6F7N>^Y|ds8l-(f9ERIiNiDKepvql1c{kC~I#W(!*t82q|6|omSmhVGv z0;w`2;YNezoNSh|K6*4ztXy@f$Bo|8qwuMi$EqP%)^lv1SpPBIfh&tK5!XPy-=F5z^81;3_}1FuYh30ma%CE&8MvX)Fnu$Lv}Q`=~GiX-nZjs3o=JfWAa z%Oj$BZ@OMEb^5rZdN4Ab)>X0)IF7)_-QB(KN}dvcq}<7rh(b0sTc8s%8~!i+Biqs2 zgxgC)O^&#$&U_PQ{#4Ni?nPg;BygvW=4d^Lh>F71IK?pac}iSf0D;a= z+VzWVp>bzrWbrD;??Xd5tXHem2=I{A5`7P35_0qZtGp zWOp2~v%D#0us>IOL9?uE^a109oa-^CLS+Dr&RmAg)xwhjI*cYQdL-!o{zyj$cSp-+ zF~`Kv3HgZl+0Im^)P;shXZFi&0cVSXT`#iRw2$%Q$Bzxri}A&8zkwgKl_utM7se_y zJtz>8u{=WT?3^bIPy(UG#t1e^UtQ`qh9V|JER2k|>N-@{?SBjz35^jWJw4W=n{TO* z?f4D>9e!_Ab5A$@;6s>nTYLSDW4|H_3(d{)iH>_o#e<856240#Vql8TgI_-+L@i8l z?$)i6i2gjkTV&e4L4BoEsE6jOv&@|iM*PM0%0Wl>zZ)_n_!GC8+JGX1n`}~4)LnlZ zn>>WMvWsvBfTDp!&MFl;(JNyXTRVU=w03z~W|&{Tt8Ql7jh6n#+%~b-QK4HZcB?5_ z=1eO{v%|DaQ)!xe6`GuuhV?*0kWgrv{LVKVQF|P*QGy%?#B4FdLka zPZsw@ud#9!g=b~`vYo4!In%8N(8G;~>}dt0)TC~k;tlt66o6^@EsWbUxF@HV)z!tV zNY%?d0GthA?J`>=5Z{V?{t$Hxl$rJ961QoN6rxDOkAY}^!J^R+aPbr_AB3w*Lq+_w z0)hH7C1ZN9*krJ@6~o*IadH2~Wjxjc5P~c(8GsJJLbm*(5pXZ9tZansv6Ma4(Xp|~ z_iy@wuNyDuBIz^xIE2j=y5}Y;J;#>gQctixV1z$NLv{T*PxzaLjcO(H+6R7`% ztyFyd_SZ&Gp8-I%ot^Dtg4dtn;U=LI0Hok&zX#~^tfyF2EqV(c{d9&*Xgi5lqO_Iu znPaIa)`xT^psUT5aVZJ2NL0bN@7D6lG6MjAAAwhx!-WJ?LEAT^$^q)tgZ=Pt({wI} zpj-FufhbvbTfdz>531`agN9?)a|6=6xIPu$CkB+1TR?w?u!3vymp+|solm!Bv%!E(kb$=QAe9gTDj%(o9Vh4U_)t3EkJza9e&PCQ`^Zu2P9G=j2jbW7HB@0cZ6ZwU&(Sf18nhWm1SdIGK=SQ3V6nG z2?V4XbSnq$rNU4a#RrVM4wM|DLF^+vKf;XjU-m81iviTiAzWHKqJkEHe$q}AbZ87y zT2DNqPN;gu{ao8Eiu5<~fn>vm=7IsRz(%U7ug(?%e=IKMHjivM14qCbEku^&=1$CQ z*7AVx8B)F<*gGsmz&<2vC+Nb_F2-C-)dzz`wc zvYNX+k@hhvzUoIa!>g{S`JV(p(=pD$9GH;_K#?vAIs_*33g=bQ`nxN`M5~Bg6-)b^ zUK{bfjmU(({0{(2(x)5Y7#kC{weeg&&97Am)rY%W94a^DMz+gE!xR;+`2K3yLUnV~ zOim-CgT*1k>nK1K*Y)HY_s=&#^Dhe{BDl+L7Vmt|izml^Vnh5yKcg>?`bxJMO4>Wy z-{QBz|B+XaMDFG zjXif_d%b#$W$T>=m#UteE-que=I zsLs9^z8Q^;`1ZhxVU;3Bf+e=J&SvCp?X2H@D97F`!puw``t)$A6^Dvwb(Q>tYy1|1!Zk%nIj4hf6(b7QlV~GX>3TwRz|Mp3B%f5kfl?^Ot)b`k6qEWD{ zV1Uoich78B#@5vdLx=kc=SQI~13gk+*8SV5sdMSJo8k**&ZU+{_9CjfF{|JzCEwfY zTw%2OU4OBNn*{@lHM(&w`Y9yTkBn=rHC`+fmp%xa$Q+h$zLER+a103iMpV_fa4Fn= zLplg3*tx2O>X;E%5PD4P(eu~6@BE5@UD5j@WS)9kJr0Pjzv4ok(otl`Qbf9Mj`T2z zc7fG0TSMcu_e}8c@_5^QloN_HcHt2BZ>|XOpWXzn1(){S z-+sKJE7EI}KkG>}LVn$L#WVNt(?qk1r*G%+drXSm+cvI6=J7o^-&~pmJTJ3=kprG% zBb`t20Dc9CBqQT|;zIWvWLPq(Q1eS8y)_FPMUUoqGMkd?}n+uR&Y z2nbiRKqTGm#AzPQaqs;<$S&Yj%8z}j3>-{FQ9`FbNh+jv3Q7D-qY3d=la#yydYr6N zHX2W^*#%J@@P4`t$j&kwY~nS|2^F;p%0>rO?Bucih9h3I-}e(fr!m@3HK?Udend*s zms+vDb66aht!OmJrgCuxkes^GN;>Iv)5^-Xm%C;N@nJ(x6(hO0Ql(Z+cF4z+vn_lq z^NVDxmXFZd{hM$}$d30)e#)AcEw94z^4%q%t@^$w z0orETy~s6s7$61Cfix2#3Dw;4{Zj+ExMD-%FvX~zB5IcJ9|6V{^#Y@_^r#I zQC_)0fa~%tIz7}Tf3$oi=ch{@J_ZteQug!Xi{!~E;y?m#$HE{Fpl?7jOle+xkCSTu zkjwb;ou|sCDj5`5K2dV8m7@TF*K;-3&~s>Bc~jm3mVNr2nMfjcK8ioLpuo1r9hlA5 z1k$gs8FSY<>fdCHl-jQxOTUWJQsZU+x&AQvsfN&bJ*o5-i376HVl-&|#u$hM`eLhf zc^z2hQnY3_MC5n>4aMF?G$%)++K5c=U<`+o`P#$Oqwa@YJ!DwBihWY#*2NzJ!}?KF->U z&Gdh=ky!{WJzuh#-eprUG+wK*gM+Z)P-JFXu*(Ll&h=GUw)e^N>e_Q-`{;tgEnke&t?Wxr zt8+HdF6E0QYj*<)w1$|vFS>0oukQD?-PhSRpe}K`)Y?rlm?~&z{^G09sR_6O8RT?l z>FDF*3q2zy5haG-w(S)fgHBg2OH3>VQ(piXce)~&Cy-Dd&AbqP7W`chJoDR~4OqRQ z!sFv(wkYcS&iDAjsT>t5gAy>xpCxxI*o0KFxImUxJ?MlH^GSu9&A472EcOw{RYQpb z@tb*a{0szPmk~;^S+={G6A(oFwe{`p3g^cGPuPtB9p5(`nZQm4nBk z_A(9wm#1{V`&vsfRFv+*(Hbgi;ASEq_8+YPyh0MFA+71zF-WFj2KNRuB*5U@0joLPbfUNo}`S#= zVa|;|_X0#-@9*##i!ZjxSkJd&f2CN5V68pu>?pdk*6zP0t-a8ygRR&UBM02lt);Vu zf%K&!6K$PuU1d~$h1kxL0b}Pk>WOSPb#@l!?FU!@LycY|P_deePAONgYm-hZo+#oB zu9cUNXkVI~T)`7gxmzQ3K~-pTmQ~Gr&$zU8;b8=@=~k}Re*K~OH|O5c(t-sU5QuUu z*oKQ)-gN_eYv4M1M>L96lb!Cp<1@aacYC+e6*e>TE}0jXM58Iu948Ebz3K2bl-Qs+GvL!g~mt0fB z7ut?F0Hk6&qFO^W{VI0GjSZNMBG2f8#D+ zi-6;B+dxX4qsE&zKrE0`YIMurJ+Mbcwif(I34Fl4Gd?ppxruzz&Y|ti+nIj+CTYmNJ$2U}$z ztija11tfUu@+h2QBmsv_N9O_M`cV=5K-$3}!s63CAraV-=IoygRs`7V_!N(RwPIpk ztIR$~4o>Sm1H^Ho06U?57)pNUM0y=o*cU^;ou+bmm13)mwDAgU1D+)X61&YuVK71F)Yr?qng|@mO z?AkoQ+fuynMBm-|LeVj!mczUJ>}OGXJp8spb8{~JSA_ih zW_2ksBxDuBTUw&-ihFbO2G%Fhp&MN#D>v=SLba2x4uMGcl&se@oSa?ya_i;Oy_=)a zMG5+J9fv~z8{a#>Z@((g`J=(bI} zH9*5FDvkj16T=~*QIgIPsPO; zl29nL(3X$$WEmd%@cDCEll|3C&0|e?s|Y-nSFiB&%irv88cp9j`_uV>-pEM7;Sl)s zOCIe`Q1h7Kvq*^Sd)62~zX~pt5$cZxS!Q*#di}FlXX6*oTFl#qZPi)RrF2i=5QnL} z5c-Gxb#6QBH#IICoZv^Iwsy7@A#8S6PkCYJ+p0-AKY7LXk~q{V0P2qg}_n&mZwE%V8vOR-y5!N&(?p9sMD^f;QIbdPF7y8k?U*fIKV~L zZMA0#R)?zm)wO2faY3QA*WaijfC=%fYR2X(vEZOp4<{lRRF5t+#Y8@5X=_yU$}7iO zl7aK`Oz^?+%7?0&JLLhD#R85#=coPSg)^1n!`Q|2VuIt`>CHRpI@^A58oq?@TFUB8 z?_^9~RmbL})e0+kOQRXo{!SF^BC@D_#VIEUfR#tjAIqj{(YrDg%#f(=*;Rs7* zzC2336wj{%Ibzz{I)qMiLf)rO;7hF)`i}lDmfT>^7hch&9)4a$Ox(=&+>Gq=uwLmj zn5K_2ql<}Ar7oooV2Wr9G!jeTgk;|Cy}|fS<}Q#5ZLa5|U{_CBp3<3N*Q+;e3HI-> z&unZSYi*EJOnk4cOTms&9*#tjo!dBFCUkO>y(B0@*}FeJ&`RL8Idx}&L`Fi)b_O;jZvl=$zen#U@6 zD)(C(I_fug9fzLHeWwx;@JxgRbcg)eQnJx0O)jLp$f4^iiQB!lKQ3CVzD5BlxZUIV+ zse<|yT09`Z6%|kU$p9JtmXi)`*Vj{nBO~0BC9Qb&QXIj5JY0^&n^Tdk*WJ(OD-~8<9>4KHR zFe<#|1rpuZ!e8yeefS8{;XovyAyVhvzh{yiddLu5US-Ww1)u8}fy;#WOy&m8{-JWm z07TX5@b4k$#R)DwaEXNU?yRO5Fm;a3H?vdhzH4BkAw1?gO7gRooRll->dS- zKkgl`H`c)+SMDvPv8d{mfAX<<;onBP8d zhbc>ryptyE7okBwjF48XEnmu?Q-oUfH=1wli`F26QRIEJw>0f{Ydt*cu<1NVUEDEz zw1N39MW70q%o)n#sjZ{cuUoy@N+j%kb-LBKEud*(rf)o6UjCbEI{-j2wO6%C9KacYj)hk}3Lt$3YLG zsw0ZKwY0o_zOKuv`MM5v%Tg9tSW#HVjID|J$Y51-I4E(6gG79G*3U68r#*E6NF4Fy z@*;bBdlBpp1zg{Rd96-YQvPLlwov*k_gMsUMuBNiOk~EFwexAE>3P!$usO`yn@$mXXfXGnQ!HfZ81Li$Uw%4v4iX=^wN6(O(4?;u* z&y#ZUtq)?uVsrH3G1s!i{5Cg^&VMX1L)7Db&`H}kb7vFal=i-R1uBF5Vgmgj z0~V96em;;V*)L7AM+U?C+~!3N3m(W4zO{i+aKW%rdrEU5cgOO_gG6hL_5h*IR$-;0 zK@1wA9T^0Rc5S5OI~aeYhqx>L+Rz7IqjNj0dqAM+)^HT^tH+%NNI8JwtQhh7{x|*n z4I#Fd5XX;x8o%>=$-a|_($wiN)`QBwQJ^GnQC^X3dqTBu1<{T2biUdLy#}`O#f|z& z%E}cUpdp^dRZU)KnFq8yPlqLU_Yqp12wE^LC-aojr zRrHe$)gZCR$7y_M$Cl(8ckx?mo_$|NAqnAs|Fi@y>WQ)W1UW!QsY1(>9%Mb;B3)T9 zS5%K>VH!*2mY?JEfjl{PT2#Rk4+~(BXZ4^{|Lij`3rZ1n7%atvMmrFU{!cp3otilP zUt~_a-XZF~&Rjq9qn|)T*fRwZ9QnT$2k*oqd}(Mn{HZSXXPpP`q%5-)IsbRTL9J(Z ze~7wqgTn1hW^ECIe^;xL=^6g)!&zo7pxop|Sk^L^v0fE1{U4g>M+}R<0X@=M@O2zm z7<_$h{?pz4(3Fdzi=*@XsG_mN!bG`TACJ?>fOqDCeCz*I`UmjPcEHoTRdKC3y zfqeFa(_e%DkUQJoZ~|BWhJ98wApd7bb)!&3LyF+ZJB9~MBF~WDO+pdb2#?NhU=q=J z&xUKq!Klw~K$rnHs-pDuJPx;?gzU{<%+{Y#i$C!X5P`j_O^(faWFVRzi7Eu*ynp++ zTX+6AzJZSa2=P?;w{c#U;y+)-{BQEoVgGyu;=#|@OA_=gpz4sD;Wza!#h-veFH<23 zcwqd;*iB>x8=51n;iAD9j)6#2R^j2#M@FEl&XJp8dry`1^>rXqv%a+!fr~jrB(#oT zZwV*H4@s(m1^xYDehsnT^0(h{S^t=mhj)P z{S^VQa3V7xWq2UsA^#p(O$g4j4+i4hVHZk}eDtdg1br|?)+3`dv%sm|COEy8aouygl={_wb*&ScYt^B1(cOLU1uH`ANEq zfm{rw%HO8`FF2xZrWF|BTde-?=7vhvNwbQl-() z)a=~RDMFAcJA6-5UA^yS`6RRLK5%*Q8`!VR*TRE>LVp*x>3h8NZSQ0{Ira~XRYZCY zwUNic{93It2phNyAVSLUn)`Xk4l%|PE)(nRbxbOwrF=~wD;42&AXpTR2_z#S*itV^ z+4U=vcT_=7Fp`Du{Xe*d{r}}2cdo<>R^vOIzuVAS3kY!y4A|*c?{1-gmtz?uLb$5g z3Zzrw{@x+}{ypx-=a^}t9mAihtN7FNw$8V`U%qtmu@QhqMPb~F>k-rz12-)pkr2YqVbc&56-C^wnF~A7E+94s%22>1WY8nvP_eMEM1Q*#$K9GN8ClNi zy?+mbes#6m^zX5{j~erDEh(uP)1(!Y@~k`E{8Q&a2NcqBg%RJsJL+?RL}0n%y8A#n zq^apka3pYpl~@!XpWtEVU6;63W5_L@wvK|~sdioRe2GmvT z{g&-wUa0XjN->4M0kuB!_qqbi>+1B?1lIjUfmMX_Zrt_7%}snK4iUAaKxs!C&2qUv z2smLlb!!GMNjc!x*YGk+s}Hz*vC%D77#P5CL?Lm|PYn&ez^w^9df-m>&Rm^G4eK)y zaPD(woCkSKuOHwu=V@ z1mOWq#S>*ElH5{%P>DF%HV(;Xx$&S&tok5XI;Xeg+x^a3ZgGEw#UMqH0gh zEmA`;@4QM;B(Pl}I2=*AqZSLeQVvEVo2K#B1K`*_Dey=u11%PZmsfpB~0 zPL?I;2QEq@zc~(CX|%r&h*oi289BZ$GDjHi0&W0!=@k@v_UJ)gBfi(Q`@ z^W*s0E^LJwxi{kDN$LDax{9f+D`(4qrPM8MILH2Lgnyj>=?X9k`t+bZ^Ae=@0T$H( zm;g7nv167o9kEanRKT*FcTms0eI*3ajD&~>0n?KYBWfaBMZ5qS{_U8x3rwiHPx;SR zpFwbh?*7ypz?vBVGb&&XMyfs_#X~`4;;{2z6EH~fq8p66WG$5t7-5sH_$2?!zx{}y zFOwiTRy7ueK!e*A2bfjXe@2vkCvvR+84UuFo{sImMuIC=FsuLB6A9gkT-|?0X_xU3 z9fyC7e3wmNRt^94M0jv4NAaJLgC_#QZ(U>23ix!@TzoUE-z!KBEXoiO48TvFeyya5LpwnRceh6VroaR&y0&}f4@pBdS8 ScHlb*BqO0HULd7i!Z`J${SMTk#@4+4P*Wu(PbK_HA+;7<+r z9`Fn7Gj=-&#BnPl{z2U{d3O#^on)c~=a3qYCEtG>%Z}dKjx|hjce&`Xv@~M=Yv)FNiC7OJ2*caXq(g#PH zM)#pT-lx*bo9{ENrU`iLH!yzhUAqrz!sTA61$>|qg<<%Y{QrIsJL`jn5^;!VZ=AM< z3w3!3@bP73WmARy2Hd{*LlSu%?2U|!92^$1FAUCy+Jn{XqNT#&wqckzxqi!`G*;ug|rVq7P}E$*$jXv$TL#6!co|9%0r) zV2Q*&+(q0Z^XvLBM# z-P;v@c(pn*C^XT7U_I`&)T?p!lCkV+52rls7#5 z*cs&Q`L({lN!r?oWJ&Ra-KMaih+5^B8;*PEyNL@M#*YX=Fk|oomx~8zZ-pctyJ~h) z{}=XiwUm{XOEoUY$D+1{cAAm}UK5>CD~0sP2Xpi28;iBDtk_tx{=@`@>rRW4#^gb_ zFQCNCpFfSaqT6vYE*^`%W_vw>b}7q7Ak33QVU*>;X)v8P4(?Xkxi!C0fssS-Z27fX zanN*${a)gMyKPMGntS1H<=*#t)VeN}?R}J4b*Ac8Cf*&KR+t#~K7m-=eJmUoeFl(S z7%rqiw>0R{u=eP4{uko|14SjeO_x%b8rs^FwT^cgop4-wwV-Uq`li$1;7<{>oN-EI(uGiohsy9IAZ9X5QgEps9C^df~w4zobh>`=a~-C#e{~G+ocR)?uMiX{>`#V!sw;UrqT_w;`(J*uDf_(6|M#)ZhIfuEu6FM zZF8T+Y~h06*!f;nM-U~8}oLeAMezgb|<1`UCyL(_`CF5?+U}$fxH!sxRoqMTCidV3r4 z%kJIV4C_2x0`|sZQ4X+y^2w{eKAiQKiGH>Lzt!0faG0)gKyT;I;nMTo)ffHk4x0(g zdtr5_CMuncfGpL%2GMx7cPBh@7GT!xhv|2LF`d_X$ZCg5NUjR*(ed$ryj|-JGn<;4 z+Ae_Ka6Che#ezjYr|df<;~4te>P`)ZbdGUSaN(lNL`+K)>zuW8wO#bdK@YWbbcRC$ zEwKJ=)%Ub)j7}KCP!>QRMG9FtxiM`zL8cF}U%#H7o@^1(2^k8%Gi>s27W-sf*{n~D zS5{GJH&X-k{4CCpbuAbd*>v-6ct-d2e#R8-PR5xS=B*ek!1?h~zS4{5<{Q#Gch*&= zp=B{$S=l=Ud@ixSi0GL-x4+YDv59`J%hr^(d_r$}-bbm8;2`69QgIPlb$tdQqiudq zOh<81ELg|EF~GX3^4k5{QZ^D^4BiOE{)CCb3j3LHF6VBmd39uNx*=WWk_j}&WcK^v zYCjohf$Ae4R#mICpU-?Gv%9BpG?T3FeUF1tHx?Vx8RvJ(3w+O>v$7bs^iJhkbJu=2cL!XO z3NE&vseDRe&l(gmKS8bOBx?)IOo^g`A=PO$UCgQ!>iqP?{_^~$QD$KVf_(!C~R6N|C z{3k0@oY{n{L;fyZ;2gL2^r_l+z~Y5*TMQ)jiw}-4E2%rgtxL>x?(OdCdDoWG<3Sj7mD@tp>KQf-QMmE|4GNFii8YVTIIg_(h1Mk zCUSNs&fR=$ZK5Wi8m3RNBAfA$@LlN5Jq-?yVFCht#WW%P3ZFZgTf-#Dxwc@U2;oS` zk2dx%tGfv!e8alGF1&b_#8&86DQ*-bCB577Vr8#;)J~^oXKt?`NOz5PYwRwd5ip`M zD^OOt~ovKPEw@0~laq2u*8kYHnZ}r|(^x zcNZ*|)iHW$*Am+Ccfq5a?r6$p4K)jLZm+*?UZvpCQ4P`~e|$3ORO~A)$dPVOM4?d3 zF~n!8FZbR6F7N>^Y|ds8l-(f9ERIiNiDKepvql1c{kC~I#W(!*t82q|6|omSmhVGv z0;w`2;YNezoNSh|K6*4ztXy@f$Bo|8qwuMi$EqP%)^lv1SpPBIfh&tK5!XPy-=F5z^81;3_}1FuYh30ma%CE&8MvX)Fnu$Lv}Q`=~GiX-nZjs3o=JfWAa z%Oj$BZ@OMEb^5rZdN4Ab)>X0)IF7)_-QB(KN}dvcq}<7rh(b0sTc8s%8~!i+Biqs2 zgxgC)O^&#$&U_PQ{#4Ni?nPg;BygvW=4d^Lh>F71IK?pac}iSf0D;a= z+VzWVp>bzrWbrD;??Xd5tXHem2=I{A5`7P35_0qZtGp zWOp2~v%D#0us>IOL9?uE^a109oa-^CLS+Dr&RmAg)xwhjI*cYQdL-!o{zyj$cSp-+ zF~`Kv3HgZl+0Im^)P;shXZFi&0cVSXT`#iRw2$%Q$Bzxri}A&8zkwgKl_utM7se_y zJtz>8u{=WT?3^bIPy(UG#t1e^UtQ`qh9V|JER2k|>N-@{?SBjz35^jWJw4W=n{TO* z?f4D>9e!_Ab5A$@;6s>nTYLSDW4|H_3(d{)iH>_o#e<856240#Vql8TgI_-+L@i8l z?$)i6i2gjkTV&e4L4BoEsE6jOv&@|iM*PM0%0Wl>zZ)_n_!GC8+JGX1n`}~4)LnlZ zn>>WMvWsvBfTDp!&MFl;(JNyXTRVU=w03z~W|&{Tt8Ql7jh6n#+%~b-QK4HZcB?5_ z=1eO{v%|DaQ)!xe6`GuuhV?*0kWgrv{LVKVQF|P*QGy%?#B4FdLka zPZsw@ud#9!g=b~`vYo4!In%8N(8G;~>}dt0)TC~k;tlt66o6^@EsWbUxF@HV)z!tV zNY%?d0GthA?J`>=5Z{V?{t$Hxl$rJ961QoN6rxDOkAY}^!J^R+aPbr_AB3w*Lq+_w z0)hH7C1ZN9*krJ@6~o*IadH2~Wjxjc5P~c(8GsJJLbm*(5pXZ9tZansv6Ma4(Xp|~ z_iy@wuNyDuBIz^xIE2j=y5}Y;J;#>gQctixV1z$NLv{T*PxzaLjcO(H+6R7`% ztyFyd_SZ&Gp8-I%ot^Dtg4dtn;U=LI0Hok&zX#~^tfyF2EqV(c{d9&*Xgi5lqO_Iu znPaIa)`xT^psUT5aVZJ2NL0bN@7D6lG6MjAAAwhx!-WJ?LEAT^$^q)tgZ=Pt({wI} zpj-FufhbvbTfdz>531`agN9?)a|6=6xIPu$CkB+1TR?w?u!3vymp+|solm!Bv%!E(kb$=QAe9gTDj%(o9Vh4U_)t3EkJza9e&PCQ`^Zu2P9G=j2jbW7HB@0cZ6ZwU&(Sf18nhWm1SdIGK=SQ3V6nG z2?V4XbSnq$rNU4a#RrVM4wM|DLF^+vKf;XjU-m81iviTiAzWHKqJkEHe$q}AbZ87y zT2DNqPN;gu{ao8Eiu5<~fn>vm=7IsRz(%U7ug(?%e=IKMHjivM14qCbEku^&=1$CQ z*7AVx8B)F<*gGsmz&<2vC+Nb_F2-C-)dzz`wc zvYNX+k@hhvzUoIa!>g{S`JV(p(=pD$9GH;_K#?vAIs_*33g=bQ`nxN`M5~Bg6-)b^ zUK{bfjmU(({0{(2(x)5Y7#kC{weeg&&97Am)rY%W94a^DMz+gE!xR;+`2K3yLUnV~ zOim-CgT*1k>nK1K*Y)HY_s=&#^Dhe{BDl+L7Vmt|izml^Vnh5yKcg>?`bxJMO4>Wy z-{QBz|B+XaMDFG zjXif_d%b#$W$T>=m#UteE-que=I zsLs9^z8Q^;`1ZhxVU;3Bf+e=J&SvCp?X2H@D97F`!puw``t)$A6^Dvwb(Q>tYy1|1!Zk%nIj4hf6(b7QlV~GX>3TwRz|Mp3B%f5kfl?^Ot)b`k6qEWD{ zV1Uoich78B#@5vdLx=kc=SQI~13gk+*8SV5sdMSJo8k**&ZU+{_9CjfF{|JzCEwfY zTw%2OU4OBNn*{@lHM(&w`Y9yTkBn=rHC`+fmp%xa$Q+h$zLER+a103iMpV_fa4Fn= zLplg3*tx2O>X;E%5PD4P(eu~6@BE5@UD5j@WS)9kJr0Pjzv4ok(otl`Qbf9Mj`T2z zc7fG0TSMcu_e}8c@_5^QloN_HcHt2BZ>|XOpWXzn1(){S z-+sKJE7EI}KkG>}LVn$L#WVNt(?qk1r*G%+drXSm+cvI6=J7o^-&~pmJTJ3=kprG% zBb`t20Dc9CBqQT|;zIWvWLPq(Q1eS8y)_FPMUUoqGMkd?}n+uR&Y z2nbiRKqTGm#AzPQaqs;<$S&Yj%8z}j3>-{FQ9`FbNh+jv3Q7D-qY3d=la#yydYr6N zHX2W^*#%J@@P4`t$j&kwY~nS|2^F;p%0>rO?Bucih9h3I-}e(fr!m@3HK?Udend*s zms+vDb66aht!OmJrgCuxkes^GN;>Iv)5^-Xm%C;N@nJ(x6(hO0Ql(Z+cF4z+vn_lq z^NVDxmXFZd{hM$}$d30)e#)AcEw94z^4%q%t@^$w z0orETy~s6s7$61Cfix2#3Dw;4{Zj+ExMD-%FvX~zB5IcJ9|6V{^#Y@_^r#I zQC_)0fa~%tIz7}Tf3$oi=ch{@J_ZteQug!Xi{!~E;y?m#$HE{Fpl?7jOle+xkCSTu zkjwb;ou|sCDj5`5K2dV8m7@TF*K;-3&~s>Bc~jm3mVNr2nMfjcK8ioLpuo1r9hlA5 z1k$gs8FSY<>fdCHl-jQxOTUWJQsZU+x&AQvsfN&bJ*o5-i376HVl-&|#u$hM`eLhf zc^z2hQnY3_MC5n>4aMF?G$%)++K5c=U<`+o`P#$Oqwa@YJ!DwBihWY#*2NzJ!}?KF->U z&Gdh=ky!{WJzuh#-eprUG+wK*gM+Z)P-JFXu*(Ll&h=GUw)e^N>e_Q-`{;tgEnke&t?Wxr zt8+HdF6E0QYj*<)w1$|vFS>0oukQD?-PhSRpe}K`)Y?rlm?~&z{^G09sR_6O8RT?l z>FDF*3q2zy5haG-w(S)fgHBg2OH3>VQ(piXce)~&Cy-Dd&AbqP7W`chJoDR~4OqRQ z!sFv(wkYcS&iDAjsT>t5gAy>xpCxxI*o0KFxImUxJ?MlH^GSu9&A472EcOw{RYQpb z@tb*a{0szPmk~;^S+={G6A(oFwe{`p3g^cGPuPtB9p5(`nZQm4nBk z_A(9wm#1{V`&vsfRFv+*(Hbgi;ASEq_8+YPyh0MFA+71zF-WFj2KNRuB*5U@0joLPbfUNo}`S#= zVa|;|_X0#-@9*##i!ZjxSkJd&f2CN5V68pu>?pdk*6zP0t-a8ygRR&UBM02lt);Vu zf%K&!6K$PuU1d~$h1kxL0b}Pk>WOSPb#@l!?FU!@LycY|P_deePAONgYm-hZo+#oB zu9cUNXkVI~T)`7gxmzQ3K~-pTmQ~Gr&$zU8;b8=@=~k}Re*K~OH|O5c(t-sU5QuUu z*oKQ)-gN_eYv4M1M>L96lb!Cp<1@aacYC+e6*e>TE}0jXM58Iu948Ebz3K2bl-Qs+GvL!g~mt0fB z7ut?F0Hk6&qFO^W{VI0GjSZNMBG2f8#D+ zi-6;B+dxX4qsE&zKrE0`YIMurJ+Mbcwif(I34Fl4Gd?ppxruzz&Y|ti+nIj+CTYmNJ$2U}$z ztija11tfUu@+h2QBmsv_N9O_M`cV=5K-$3}!s63CAraV-=IoygRs`7V_!N(RwPIpk ztIR$~4o>Sm1H^Ho06U?57)pNUM0y=o*cU^;ou+bmm13)mwDAgU1D+)X61&YuVK71F)Yr?qng|@mO z?AkoQ+fuynMBm-|LeVj!mczUJ>}OGXJp8spb8{~JSA_ih zW_2ksBxDuBTUw&-ihFbO2G%Fhp&MN#D>v=SLba2x4uMGcl&se@oSa?ya_i;Oy_=)a zMG5+J9fv~z8{a#>Z@((g`J=(bI} zH9*5FDvkj16T=~*QIgIPsPO; zl29nL(3X$$WEmd%@cDCEll|3C&0|e?s|Y-nSFiB&%irv88cp9j`_uV>-pEM7;Sl)s zOCIe`Q1h7Kvq*^Sd)62~zX~pt5$cZxS!Q*#di}FlXX6*oTFl#qZPi)RrF2i=5QnL} z5c-Gxb#6QBH#IICoZv^Iwsy7@A#8S6PkCYJ+p0-AKY7LXk~q{V0P2qg}_n&mZwE%V8vOR-y5!N&(?p9sMD^f;QIbdPF7y8k?U*fIKV~L zZMA0#R)?zm)wO2faY3QA*WaijfC=%fYR2X(vEZOp4<{lRRF5t+#Y8@5X=_yU$}7iO zl7aK`Oz^?+%7?0&JLLhD#R85#=coPSg)^1n!`Q|2VuIt`>CHRpI@^A58oq?@TFUB8 z?_^9~RmbL})e0+kOQRXo{!SF^BC@D_#VIEUfR#tjAIqj{(YrDg%#f(=*;Rs7* zzC2336wj{%Ibzz{I)qMiLf)rO;7hF)`i}lDmfT>^7hch&9)4a$Ox(=&+>Gq=uwLmj zn5K_2ql<}Ar7oooV2Wr9G!jeTgk;|Cy}|fS<}Q#5ZLa5|U{_CBp3<3N*Q+;e3HI-> z&unZSYi*EJOnk4cOTms&9*#tjo!dBFCUkO>y(B0@*}FeJ&`RL8Idx}&L`Fi)b_O;jZvl=$zen#U@6 zD)(C(I_fug9fzLHeWwx;@JxgRbcg)eQnJx0O)jLp$f4^iiQB!lKQ3CVzD5BlxZUIV+ zse<|yT09`Z6%|kU$p9JtmXi)`*Vj{nBO~0BC9Qb&QXIj5JY0^&n^Tdk*WJ(OD-~8<9>4KHR zFe<#|1rpuZ!e8yeefS8{;XovyAyVhvzh{yiddLu5US-Ww1)u8}fy;#WOy&m8{-JWm z07TX5@b4k$#R)DwaEXNU?yRO5Fm;a3H?vdhzH4BkAw1?gO7gRooRll->dS- zKkgl`H`c)+SMDvPv8d{mfAX<<;onBP8d zhbc>ryptyE7okBwjF48XEnmu?Q-oUfH=1wli`F26QRIEJw>0f{Ydt*cu<1NVUEDEz zw1N39MW70q%o)n#sjZ{cuUoy@N+j%kb-LBKEud*(rf)o6UjCbEI{-j2wO6%C9KacYj)hk}3Lt$3YLG zsw0ZKwY0o_zOKuv`MM5v%Tg9tSW#HVjID|J$Y51-I4E(6gG79G*3U68r#*E6NF4Fy z@*;bBdlBpp1zg{Rd96-YQvPLlwov*k_gMsUMuBNiOk~EFwexAE>3P!$usO`yn@$mXXfXGnQ!HfZ81Li$Uw%4v4iX=^wN6(O(4?;u* z&y#ZUtq)?uVsrH3G1s!i{5Cg^&VMX1L)7Db&`H}kb7vFal=i-R1uBF5Vgmgj z0~V96em;;V*)L7AM+U?C+~!3N3m(W4zO{i+aKW%rdrEU5cgOO_gG6hL_5h*IR$-;0 zK@1wA9T^0Rc5S5OI~aeYhqx>L+Rz7IqjNj0dqAM+)^HT^tH+%NNI8JwtQhh7{x|*n z4I#Fd5XX;x8o%>=$-a|_($wiN)`QBwQJ^GnQC^X3dqTBu1<{T2biUdLy#}`O#f|z& z%E}cUpdp^dRZU)KnFq8yPlqLU_Yqp12wE^LC-aojr zRrHe$)gZCR$7y_M$Cl(8ckx?mo_$|NAqnAs|Fi@y>WQ)W1UW!QsY1(>9%Mb;B3)T9 zS5%K>VH!*2mY?JEfjl{PT2#Rk4+~(BXZ4^{|Lij`3rZ1n7%atvMmrFU{!cp3otilP zUt~_a-XZF~&Rjq9qn|)T*fRwZ9QnT$2k*oqd}(Mn{HZSXXPpP`q%5-)IsbRTL9J(Z ze~7wqgTn1hW^ECIe^;xL=^6g)!&zo7pxop|Sk^L^v0fE1{U4g>M+}R<0X@=M@O2zm z7<_$h{?pz4(3Fdzi=*@XsG_mN!bG`TACJ?>fOqDCeCz*I`UmjPcEHoTRdKC3y zfqeFa(_e%DkUQJoZ~|BWhJ98wApd7bb)!&3LyF+ZJB9~MBF~WDO+pdb2#?NhU=q=J z&xUKq!Klw~K$rnHs-pDuJPx;?gzU{<%+{Y#i$C!X5P`j_O^(faWFVRzi7Eu*ynp++ zTX+6AzJZSa2=P?;w{c#U;y+)-{BQEoVgGyu;=#|@OA_=gpz4sD;Wza!#h-veFH<23 zcwqd;*iB>x8=51n;iAD9j)6#2R^j2#M@FEl&XJp8dry`1^>rXqv%a+!fr~jrB(#oT zZwV*H4@s(m1^xYDehsnT^0(h{S^t=mhj)P z{S^VQa3V7xWq2UsA^#p(O$g4j4+i4hVHZk}eDtdg1br|?)+3`dv%sm|COEy8aouygl={_wb*&ScYt^B1(cOLU1uH`ANEq zfm{rw%HO8`FF2xZrWF|BTde-?=7vhvNwbQl-() z)a=~RDMFAcJA6-5UA^yS`6RRLK5%*Q8`!VR*TRE>LVp*x>3h8NZSQ0{Ira~XRYZCY zwUNic{93It2phNyAVSLUn)`Xk4l%|PE)(nRbxbOwrF=~wD;42&AXpTR2_z#S*itV^ z+4U=vcT_=7Fp`Du{Xe*d{r}}2cdo<>R^vOIzuVAS3kY!y4A|*c?{1-gmtz?uLb$5g z3Zzrw{@x+}{ypx-=a^}t9mAihtN7FNw$8V`U%qtmu@QhqMPb~F>k-rz12-)pkr2YqVbc&56-C^wnF~A7E+94s%22>1WY8nvP_eMEM1Q*#$K9GN8ClNi zy?+mbes#6m^zX5{j~erDEh(uP)1(!Y@~k`E{8Q&a2NcqBg%RJsJL+?RL}0n%y8A#n zq^apka3pYpl~@!XpWtEVU6;63W5_L@wvK|~sdioRe2GmvT z{g&-wUa0XjN->4M0kuB!_qqbi>+1B?1lIjUfmMX_Zrt_7%}snK4iUAaKxs!C&2qUv z2smLlb!!GMNjc!x*YGk+s}Hz*vC%D77#P5CL?Lm|PYn&ez^w^9df-m>&Rm^G4eK)y zaPD(woCkSKuOHwu=V@ z1mOWq#S>*ElH5{%P>DF%HV(;Xx$&S&tok5XI;Xeg+x^a3ZgGEw#UMqH0gh zEmA`;@4QM;B(Pl}I2=*AqZSLeQVvEVo2K#B1K`*_Dey=u11%PZmsfpB~0 zPL?I;2QEq@zc~(CX|%r&h*oi289BZ$GDjHi0&W0!=@k@v_UJ)gBfi(Q`@ z^W*s0E^LJwxi{kDN$LDax{9f+D`(4qrPM8MILH2Lgnyj>=?X9k`t+bZ^Ae=@0T$H( zm;g7nv167o9kEanRKT*FcTms0eI*3ajD&~>0n?KYBWfaBMZ5qS{_U8x3rwiHPx;SR zpFwbh?*7ypz?vBVGb&&XMyfs_#X~`4;;{2z6EH~fq8p66WG$5t7-5sH_$2?!zx{}y zFOwiTRy7ueK!e*A2bfjXe@2vkCvvR+84UuFo{sImMuIC=FsuLB6A9gkT-|?0X_xU3 z9fyC7e3wmNRt^94M0jv4NAaJLgC_#QZ(U>23ix!@TzoUE-z!KBEXoiO48TvFeyya5LpwnRceh6VroaR&y0&}f4@pBdS8 ScHlb*BqO0HULd7i!Z`J${SMTk#@4+4P*Wu(PbK_HA+;7<+r z9`Fn7Gj=-&#BnPl{z2U{d3O#^on)c~=a3qYCEtG>%Z}dKjx|hjce&`Xv@~M=Yv)FNiC7OJ2*caXq(g#PH zM)#pT-lx*bo9{ENrU`iLH!yzhUAqrz!sTA61$>|qg<<%Y{QrIsJL`jn5^;!VZ=AM< z3w3!3@bP73WmARy2Hd{*LlSu%?2U|!92^$1FAUCy+Jn{XqNT#&wqckzxqi!`G*;ug|rVq7P}E$*$jXv$TL#6!co|9%0r) zV2Q*&+(q0Z^XvLBM# z-P;v@c(pn*C^XT7U_I`&)T?p!lCkV+52rls7#5 z*cs&Q`L({lN!r?oWJ&Ra-KMaih+5^B8;*PEyNL@M#*YX=Fk|oomx~8zZ-pctyJ~h) z{}=XiwUm{XOEoUY$D+1{cAAm}UK5>CD~0sP2Xpi28;iBDtk_tx{=@`@>rRW4#^gb_ zFQCNCpFfSaqT6vYE*^`%W_vw>b}7q7Ak33QVU*>;X)v8P4(?Xkxi!C0fssS-Z27fX zanN*${a)gMyKPMGntS1H<=*#t)VeN}?R}J4b*Ac8Cf*&KR+t#~K7m-=eJmUoeFl(S z7%rqiw>0R{u=eP4{uko|14SjeO_x%b8rs^FwT^cgop4-wwV-Uq`li$1;7<{>oN-EI(uGiohsy9IAZ9X5QgEps9C^df~w4zobh>`=a~-C#e{~G+ocR)?uMiX{>`#V!sw;UrqT_w;`(J*uDf_(6|M#)ZhIfuEu6FM zZF8T+Y~h06*!f;nM-U~8}oLeAMezgb|<1`UCyL(_`CF5?+U}$fxH!sxRoqMTCidV3r4 z%kJIV4C_2x0`|sZQ4X+y^2w{eKAiQKiGH>Lzt!0faG0)gKyT;I;nMTo)ffHk4x0(g zdtr5_CMuncfGpL%2GMx7cPBh@7GT!xhv|2LF`d_X$ZCg5NUjR*(ed$ryj|-JGn<;4 z+Ae_Ka6Che#ezjYr|df<;~4te>P`)ZbdGUSaN(lNL`+K)>zuW8wO#bdK@YWbbcRC$ zEwKJ=)%Ub)j7}KCP!>QRMG9FtxiM`zL8cF}U%#H7o@^1(2^k8%Gi>s27W-sf*{n~D zS5{GJH&X-k{4CCpbuAbd*>v-6ct-d2e#R8-PR5xS=B*ek!1?h~zS4{5<{Q#Gch*&= zp=B{$S=l=Ud@ixSi0GL-x4+YDv59`J%hr^(d_r$}-bbm8;2`69QgIPlb$tdQqiudq zOh<81ELg|EF~GX3^4k5{QZ^D^4BiOE{)CCb3j3LHF6VBmd39uNx*=WWk_j}&WcK^v zYCjohf$Ae4R#mICpU-?Gv%9BpG?T3FeUF1tHx?Vx8RvJ(3w+O>v$7bs^iJhkbJu=2cL!XO z3NE&vseDRe&l(gmKS8bOBx?)IOo^g`A=PO$UCgQ!>iqP?{_^~$QD$KVf_(!C~R6N|C z{3k0@oY{n{L;fyZ;2gL2^r_l+z~Y5*TMQ)jiw}-4E2%rgtxL>x?(OdCdDoWG<3Sj7mD@tp>KQf-QMmE|4GNFii8YVTIIg_(h1Mk zCUSNs&fR=$ZK5Wi8m3RNBAfA$@LlN5Jq-?yVFCht#WW%P3ZFZgTf-#Dxwc@U2;oS` zk2dx%tGfv!e8alGF1&b_#8&86DQ*-bCB577Vr8#;)J~^oXKt?`NOz5PYwRwd5ip`M zD^OOt~ovKPEw@0~laq2u*8kYHnZ}r|(^x zcNZ*|)iHW$*Am+Ccfq5a?r6$p4K)jLZm+*?UZvpCQ4P`~e|$3ORO~A)$dPVOM4?d3 zF~n!8FZbR6F7N>^Y|ds8l-(f9ERIiNiDKepvql1c{kC~I#W(!*t82q|6|omSmhVGv z0;w`2;YNezoNSh|K6*4ztXy@f$Bo|8qwuMi$EqP%)^lv1SpPBIfh&tK5!XPy-=F5z^81;3_}1FuYh30ma%CE&8MvX)Fnu$Lv}Q`=~GiX-nZjs3o=JfWAa z%Oj$BZ@OMEb^5rZdN4Ab)>X0)IF7)_-QB(KN}dvcq}<7rh(b0sTc8s%8~!i+Biqs2 zgxgC)O^&#$&U_PQ{#4Ni?nPg;BygvW=4d^Lh>F71IK?pac}iSf0D;a= z+VzWVp>bzrWbrD;??Xd5tXHem2=I{A5`7P35_0qZtGp zWOp2~v%D#0us>IOL9?uE^a109oa-^CLS+Dr&RmAg)xwhjI*cYQdL-!o{zyj$cSp-+ zF~`Kv3HgZl+0Im^)P;shXZFi&0cVSXT`#iRw2$%Q$Bzxri}A&8zkwgKl_utM7se_y zJtz>8u{=WT?3^bIPy(UG#t1e^UtQ`qh9V|JER2k|>N-@{?SBjz35^jWJw4W=n{TO* z?f4D>9e!_Ab5A$@;6s>nTYLSDW4|H_3(d{)iH>_o#e<856240#Vql8TgI_-+L@i8l z?$)i6i2gjkTV&e4L4BoEsE6jOv&@|iM*PM0%0Wl>zZ)_n_!GC8+JGX1n`}~4)LnlZ zn>>WMvWsvBfTDp!&MFl;(JNyXTRVU=w03z~W|&{Tt8Ql7jh6n#+%~b-QK4HZcB?5_ z=1eO{v%|DaQ)!xe6`GuuhV?*0kWgrv{LVKVQF|P*QGy%?#B4FdLka zPZsw@ud#9!g=b~`vYo4!In%8N(8G;~>}dt0)TC~k;tlt66o6^@EsWbUxF@HV)z!tV zNY%?d0GthA?J`>=5Z{V?{t$Hxl$rJ961QoN6rxDOkAY}^!J^R+aPbr_AB3w*Lq+_w z0)hH7C1ZN9*krJ@6~o*IadH2~Wjxjc5P~c(8GsJJLbm*(5pXZ9tZansv6Ma4(Xp|~ z_iy@wuNyDuBIz^xIE2j=y5}Y;J;#>gQctixV1z$NLv{T*PxzaLjcO(H+6R7`% ztyFyd_SZ&Gp8-I%ot^Dtg4dtn;U=LI0Hok&zX#~^tfyF2EqV(c{d9&*Xgi5lqO_Iu znPaIa)`xT^psUT5aVZJ2NL0bN@7D6lG6MjAAAwhx!-WJ?LEAT^$^q)tgZ=Pt({wI} zpj-FufhbvbTfdz>531`agN9?)a|6=6xIPu$CkB+1TR?w?u!3vymp+|solm!Bv%!E(kb$=QAe9gTDj%(o9Vh4U_)t3EkJza9e&PCQ`^Zu2P9G=j2jbW7HB@0cZ6ZwU&(Sf18nhWm1SdIGK=SQ3V6nG z2?V4XbSnq$rNU4a#RrVM4wM|DLF^+vKf;XjU-m81iviTiAzWHKqJkEHe$q}AbZ87y zT2DNqPN;gu{ao8Eiu5<~fn>vm=7IsRz(%U7ug(?%e=IKMHjivM14qCbEku^&=1$CQ z*7AVx8B)F<*gGsmz&<2vC+Nb_F2-C-)dzz`wc zvYNX+k@hhvzUoIa!>g{S`JV(p(=pD$9GH;_K#?vAIs_*33g=bQ`nxN`M5~Bg6-)b^ zUK{bfjmU(({0{(2(x)5Y7#kC{weeg&&97Am)rY%W94a^DMz+gE!xR;+`2K3yLUnV~ zOim-CgT*1k>nK1K*Y)HY_s=&#^Dhe{BDl+L7Vmt|izml^Vnh5yKcg>?`bxJMO4>Wy z-{QBz|B+XaMDFG zjXif_d%b#$W$T>=m#UteE-que=I zsLs9^z8Q^;`1ZhxVU;3Bf+e=J&SvCp?X2H@D97F`!puw``t)$A6^Dvwb(Q>tYy1|1!Zk%nIj4hf6(b7QlV~GX>3TwRz|Mp3B%f5kfl?^Ot)b`k6qEWD{ zV1Uoich78B#@5vdLx=kc=SQI~13gk+*8SV5sdMSJo8k**&ZU+{_9CjfF{|JzCEwfY zTw%2OU4OBNn*{@lHM(&w`Y9yTkBn=rHC`+fmp%xa$Q+h$zLER+a103iMpV_fa4Fn= zLplg3*tx2O>X;E%5PD4P(eu~6@BE5@UD5j@WS)9kJr0Pjzv4ok(otl`Qbf9Mj`T2z zc7fG0TSMcu_e}8c@_5^QloN_HcHt2BZ>|XOpWXzn1(){S z-+sKJE7EI}KkG>}LVn$L#WVNt(?qk1r*G%+drXSm+cvI6=J7o^-&~pmJTJ3=kprG% zBb`t20Dc9CBqQT|;zIWvWLPq(Q1eS8y)_FPMUUoqGMkd?}n+uR&Y z2nbiRKqTGm#AzPQaqs;<$S&Yj%8z}j3>-{FQ9`FbNh+jv3Q7D-qY3d=la#yydYr6N zHX2W^*#%J@@P4`t$j&kwY~nS|2^F;p%0>rO?Bucih9h3I-}e(fr!m@3HK?Udend*s zms+vDb66aht!OmJrgCuxkes^GN;>Iv)5^-Xm%C;N@nJ(x6(hO0Ql(Z+cF4z+vn_lq z^NVDxmXFZd{hM$}$d30)e#)AcEw94z^4%q%t@^$w z0orETy~s6s7$61Cfix2#3Dw;4{Zj+ExMD-%FvX~zB5IcJ9|6V{^#Y@_^r#I zQC_)0fa~%tIz7}Tf3$oi=ch{@J_ZteQug!Xi{!~E;y?m#$HE{Fpl?7jOle+xkCSTu zkjwb;ou|sCDj5`5K2dV8m7@TF*K;-3&~s>Bc~jm3mVNr2nMfjcK8ioLpuo1r9hlA5 z1k$gs8FSY<>fdCHl-jQxOTUWJQsZU+x&AQvsfN&bJ*o5-i376HVl-&|#u$hM`eLhf zc^z2hQnY3_MC5n>4aMF?G$%)++K5c=U<`+o`P#$Oqwa@YJ!DwBihWY#*2NzJ!}?KF->U z&Gdh=ky!{WJzuh#-eprUG+wK*gM+Z)P-JFXu*(Ll&h=GUw)e^N>e_Q-`{;tgEnke&t?Wxr zt8+HdF6E0QYj*<)w1$|vFS>0oukQD?-PhSRpe}K`)Y?rlm?~&z{^G09sR_6O8RT?l z>FDF*3q2zy5haG-w(S)fgHBg2OH3>VQ(piXce)~&Cy-Dd&AbqP7W`chJoDR~4OqRQ z!sFv(wkYcS&iDAjsT>t5gAy>xpCxxI*o0KFxImUxJ?MlH^GSu9&A472EcOw{RYQpb z@tb*a{0szPmk~;^S+={G6A(oFwe{`p3g^cGPuPtB9p5(`nZQm4nBk z_A(9wm#1{V`&vsfRFv+*(Hbgi;ASEq_8+YPyh0MFA+71zF-WFj2KNRuB*5U@0joLPbfUNo}`S#= zVa|;|_X0#-@9*##i!ZjxSkJd&f2CN5V68pu>?pdk*6zP0t-a8ygRR&UBM02lt);Vu zf%K&!6K$PuU1d~$h1kxL0b}Pk>WOSPb#@l!?FU!@LycY|P_deePAONgYm-hZo+#oB zu9cUNXkVI~T)`7gxmzQ3K~-pTmQ~Gr&$zU8;b8=@=~k}Re*K~OH|O5c(t-sU5QuUu z*oKQ)-gN_eYv4M1M>L96lb!Cp<1@aacYC+e6*e>TE}0jXM58Iu948Ebz3K2bl-Qs+GvL!g~mt0fB z7ut?F0Hk6&qFO^W{VI0GjSZNMBG2f8#D+ zi-6;B+dxX4qsE&zKrE0`YIMurJ+Mbcwif(I34Fl4Gd?ppxruzz&Y|ti+nIj+CTYmNJ$2U}$z ztija11tfUu@+h2QBmsv_N9O_M`cV=5K-$3}!s63CAraV-=IoygRs`7V_!N(RwPIpk ztIR$~4o>Sm1H^Ho06U?57)pNUM0y=o*cU^;ou+bmm13)mwDAgU1D+)X61&YuVK71F)Yr?qng|@mO z?AkoQ+fuynMBm-|LeVj!mczUJ>}OGXJp8spb8{~JSA_ih zW_2ksBxDuBTUw&-ihFbO2G%Fhp&MN#D>v=SLba2x4uMGcl&se@oSa?ya_i;Oy_=)a zMG5+J9fv~z8{a#>Z@((g`J=(bI} zH9*5FDvkj16T=~*QIgIPsPO; zl29nL(3X$$WEmd%@cDCEll|3C&0|e?s|Y-nSFiB&%irv88cp9j`_uV>-pEM7;Sl)s zOCIe`Q1h7Kvq*^Sd)62~zX~pt5$cZxS!Q*#di}FlXX6*oTFl#qZPi)RrF2i=5QnL} z5c-Gxb#6QBH#IICoZv^Iwsy7@A#8S6PkCYJ+p0-AKY7LXk~q{V0P2qg}_n&mZwE%V8vOR-y5!N&(?p9sMD^f;QIbdPF7y8k?U*fIKV~L zZMA0#R)?zm)wO2faY3QA*WaijfC=%fYR2X(vEZOp4<{lRRF5t+#Y8@5X=_yU$}7iO zl7aK`Oz^?+%7?0&JLLhD#R85#=coPSg)^1n!`Q|2VuIt`>CHRpI@^A58oq?@TFUB8 z?_^9~RmbL})e0+kOQRXo{!SF^BC@D_#VIEUfR#tjAIqj{(YrDg%#f(=*;Rs7* zzC2336wj{%Ibzz{I)qMiLf)rO;7hF)`i}lDmfT>^7hch&9)4a$Ox(=&+>Gq=uwLmj zn5K_2ql<}Ar7oooV2Wr9G!jeTgk;|Cy}|fS<}Q#5ZLa5|U{_CBp3<3N*Q+;e3HI-> z&unZSYi*EJOnk4cOTms&9*#tjo!dBFCUkO>y(B0@*}FeJ&`RL8Idx}&L`Fi)b_O;jZvl=$zen#U@6 zD)(C(I_fug9fzLHeWwx;@JxgRbcg)eQnJx0O)jLp$f4^iiQB!lKQ3CVzD5BlxZUIV+ zse<|yT09`Z6%|kU$p9JtmXi)`*Vj{nBO~0BC9Qb&QXIj5JY0^&n^Tdk*WJ(OD-~8<9>4KHR zFe<#|1rpuZ!e8yeefS8{;XovyAyVhvzh{yiddLu5US-Ww1)u8}fy;#WOy&m8{-JWm z07TX5@b4k$#R)DwaEXNU?yRO5Fm;a3H?vdhzH4BkAw1?gO7gRooRll->dS- zKkgl`H`c)+SMDvPv8d{mfAX<<;onBP8d zhbc>ryptyE7okBwjF48XEnmu?Q-oUfH=1wli`F26QRIEJw>0f{Ydt*cu<1NVUEDEz zw1N39MW70q%o)n#sjZ{cuUoy@N+j%kb-LBKEud*(rf)o6UjCbEI{-j2wO6%C9KacYj)hk}3Lt$3YLG zsw0ZKwY0o_zOKuv`MM5v%Tg9tSW#HVjID|J$Y51-I4E(6gG79G*3U68r#*E6NF4Fy z@*;bBdlBpp1zg{Rd96-YQvPLlwov*k_gMsUMuBNiOk~EFwexAE>3P!$usO`yn@$mXXfXGnQ!HfZ81Li$Uw%4v4iX=^wN6(O(4?;u* z&y#ZUtq)?uVsrH3G1s!i{5Cg^&VMX1L)7Db&`H}kb7vFal=i-R1uBF5Vgmgj z0~V96em;;V*)L7AM+U?C+~!3N3m(W4zO{i+aKW%rdrEU5cgOO_gG6hL_5h*IR$-;0 zK@1wA9T^0Rc5S5OI~aeYhqx>L+Rz7IqjNj0dqAM+)^HT^tH+%NNI8JwtQhh7{x|*n z4I#Fd5XX;x8o%>=$-a|_($wiN)`QBwQJ^GnQC^X3dqTBu1<{T2biUdLy#}`O#f|z& z%E}cUpdp^dRZU)KnFq8yPlqLU_Yqp12wE^LC-aojr zRrHe$)gZCR$7y_M$Cl(8ckx?mo_$|NAqnAs|Fi@y>WQ)W1UW!QsY1(>9%Mb;B3)T9 zS5%K>VH!*2mY?JEfjl{PT2#Rk4+~(BXZ4^{|Lij`3rZ1n7%atvMmrFU{!cp3otilP zUt~_a-XZF~&Rjq9qn|)T*fRwZ9QnT$2k*oqd}(Mn{HZSXXPpP`q%5-)IsbRTL9J(Z ze~7wqgTn1hW^ECIe^;xL=^6g)!&zo7pxop|Sk^L^v0fE1{U4g>M+}R<0X@=M@O2zm z7<_$h{?pz4(3Fdzi=*@XsG_mN!bG`TACJ?>fOqDCeCz*I`UmjPcEHoTRdKC3y zfqeFa(_e%DkUQJoZ~|BWhJ98wApd7bb)!&3LyF+ZJB9~MBF~WDO+pdb2#?NhU=q=J z&xUKq!Klw~K$rnHs-pDuJPx;?gzU{<%+{Y#i$C!X5P`j_O^(faWFVRzi7Eu*ynp++ zTX+6AzJZSa2=P?;w{c#U;y+)-{BQEoVgGyu;=#|@OA_=gpz4sD;Wza!#h-veFH<23 zcwqd;*iB>x8=51n;iAD9j)6#2R^j2#M@FEl&XJp8dry`1^>rXqv%a+!fr~jrB(#oT zZwV*H4@s(m1^xYDehsnT^0(h{S^t=mhj)P z{S^VQa3V7xWq2UsA^#p(O$g4j4+i4hVHZk}eDtdg1br|?)+3`dv%sm|COEy8aouygl={_wb*&ScYt^B1(cOLU1uH`ANEq zfm{rw%HO8`FF2xZrWF|BTde-?=7vhvNwbQl-() z)a=~RDMFAcJA6-5UA^yS`6RRLK5%*Q8`!VR*TRE>LVp*x>3h8NZSQ0{Ira~XRYZCY zwUNic{93It2phNyAVSLUn)`Xk4l%|PE)(nRbxbOwrF=~wD;42&AXpTR2_z#S*itV^ z+4U=vcT_=7Fp`Du{Xe*d{r}}2cdo<>R^vOIzuVAS3kY!y4A|*c?{1-gmtz?uLb$5g z3Zzrw{@x+}{ypx-=a^}t9mAihtN7FNw$8V`U%qtmu@QhqMPb~F>k-rz12-)pkr2YqVbc&56-C^wnF~A7E+94s%22>1WY8nvP_eMEM1Q*#$K9GN8ClNi zy?+mbes#6m^zX5{j~erDEh(uP)1(!Y@~k`E{8Q&a2NcqBg%RJsJL+?RL}0n%y8A#n zq^apka3pYpl~@!XpWtEVU6;63W5_L@wvK|~sdioRe2GmvT z{g&-wUa0XjN->4M0kuB!_qqbi>+1B?1lIjUfmMX_Zrt_7%}snK4iUAaKxs!C&2qUv z2smLlb!!GMNjc!x*YGk+s}Hz*vC%D77#P5CL?Lm|PYn&ez^w^9df-m>&Rm^G4eK)y zaPD(woCkSKuOHwu=V@ z1mOWq#S>*ElH5{%P>DF%HV(;Xx$&S&tok5XI;Xeg+x^a3ZgGEw#UMqH0gh zEmA`;@4QM;B(Pl}I2=*AqZSLeQVvEVo2K#B1K`*_Dey=u11%PZmsfpB~0 zPL?I;2QEq@zc~(CX|%r&h*oi289BZ$GDjHi0&W0!=@k@v_UJ)gBfi(Q`@ z^W*s0E^LJwxi{kDN$LDax{9f+D`(4qrPM8MILH2Lgnyj>=?X9k`t+bZ^Ae=@0T$H( zm;g7nv167o9kEanRKT*FcTms0eI*3ajD&~>0n?KYBWfaBMZ5qS{_U8x3rwiHPx;SR zpFwbh?*7ypz?vBVGb&&XMyfs_#X~`4;;{2z6EH~fq8p66WG$5t7-5sH_$2?!zx{}y zFOwiTRy7ueK!e*A2bfjXe@2vkCvvR+84UuFo{sImMuIC=FsuLB6A9gkT-|?0X_xU3 z9fyC7e3wmNRt^94M0jv4NAaJLgC_#QZ(U>23ix!@TzoUE-z!KBEXoiO48TvFeyya5LpwnRceh6VroaR&y0&}f4@pBdS8 ScHlb*BqO0HULEX>4Tx04R}tkv&MmP!xqvQ>7vm2Q!EWW~eS&5EXIMDionYs1;guFnQ@8G-*g$ zTpR`0f`dPcRR zta0Arte2{+*(ZNtIBTpdbDe4saV%m9G9;*|qKFbIh|+G5Vk1fCF(3bc>zB!;lB)0Yqi@OpeYZgOnm4!RIZhvd6wNAe0~{Oz zBYDbR_jz|$``rG$)12QAf&p@^Qy$^*00006VoOIv0RI600RN!9r;`8x010qNS#tmY z4c7nw4c7reD4Tcy000McNliru=m!)BASw{()Mfwx5?Dz@K~#9!?Ok_#6y?@_&dh8{ zkSawa2ps#qu7Ci19Fd{1?LoW{Kma%!_O&FEO8!uoV)&|&~q5B{tw1l_Gi zDKNDF__x*;Xtf3bG#)_x;&-550ZgC=epqdc?f33PBrPqc?H5iI(bOLgEf`f{1i(p= ztsqH@0ECrc_e5CGk6Nw#!ym}MgfnNWRu0*|eMY|D_W+#KIT|Ew1S1R#lwpg68#{PQ zm)3qlJ^<9Nbs?*(-#3Ar)M*GvECe_hWn}--76ND+_}9*l=sVAki6WA|(V6_e>cBw1 zE&;ry(@-$SdpNEv5Jl1hE4S!DJ_t(z*u9h@Rmj|6b;`mMe&+zZC1NO8!~;y_9QoI8 zRlp*Vw{&{I$H@l(Mbe~xepdo`bJQ>v6weG|26Wh}un<3TQpDeVnEb!8HOP|=X}AD5 zDe^gn_*93HfBjaK8Hwc;K~z|5q;cc}0GdQKiV61%0r2L?5iEE*7^E-vm$#}ckjNX$ zCW5H2*hmI{Yasard6VF=W{?aepZPpBb-v`MDfJ$!78SRrLfHy^qWbnU@GI%P?ivkgB>e$9t$iL*@ z{ad#lTCRHzWZlW%(BU-(ro#NNgE{)Hn$6Db+;5p{dh!c!OC^`0O_H2;XN1k5Pk1tQ%XfPZd3is4h_|J4=SAAcy%l6=5+_{=%EmJfs+ z7FMUl7injcvrQua{<-}qW_lk!3L@Aa5BC`nNW^?PHPf-#6awJ&?O$hx&ys(g6VPuC z>(O%;OfCY(9ckwspBOFxZ9}`!40;d#0zz=)!+Esb>>?o4Y+U2s#MDejx?uv)*0mcA zrYZ0<5D*;M)(1oYa9XBgc^S^nL*xT~NB-3ig3i4%rAFq#Bi6DQI=AK<%6M(k1-9!-o6BPtR930g) zkGDB&W)(q=v`ohwuLwZ<>CraYK^d7BC9{d3L1@i;7p0wZoYhqT z+D{L^3C2HAK~dlYxx8(UJtOm?$((@kXIiFXt!@I));#)FMJE4RC&=Y(4s(fsfX4Nk z)%?%Vqen$a{=Zb$0<^V{ybbUh2;eU#s5x!)nQEc+^HWU})@`H1|gG!ykbF{5{f=-^#l53cx4A|4oI}$v$4xp`mA!zzJlD#HrLwM^d?Z0Bu$HBoG4RmsA82 z*ZO$O>B@~l+=joXf`LKh>H)ML!zTeWEGOZe)eQ`#c3913E7Q*H z$tbk|w8ZeqAOy%ShX|s=bDY2I3_5c$+qiPT0wV3aV@0W&!2Ffr?-F55fX4De6Lj*$ z$#rsnKV?{Bm}Sk)uDkri^4tI(?C_Sjsv5L37^{Wn@yHbZ~E8_C8V}` z%-a^g5dCld$2Irgs=4KKnC{_6|AeX`3EY8KwT<8-t)lNOoH};!G@OuLcLGMW>DRl zUMvSx_{yL}z~B6veJqgO&fAqPTf*h8N%v?Hw_xyo!<|6lkofV(sP3(*x169yr?97Q zxT{%W=G*+0duHsDbLz*@6T2>4o)~x9nye^tJ7VlLIzEL^KYsi%)UDx_a$pr?i7liA za20%?P~_$_l7`0^yw3zqP(OacarTA?n0fGx0$@JtzYf4Ry&g_Iww_ir z>cEWsCUAl~;>Tr(I@R;j^=$)iZ^uS`+FaiQobuNx7ZchPnUEe^Pb(64V9)`hV^w6) zV8-}#t#%EaMIc@KIJv|ENzm!_oJMaXcqlJx)3nHhblG-FajPAeIpwCm!@QSRJV(Iu z%woS@@hXzm;PY|ERXs&OK;Le+cMHB!0*Jr&<2gVjO``Al+xv?A*aY?3eo|qz0|SQ& z`Hi&_iTma5zkMcm`!!y%x$k3Oy~XDjbxp6HpsKr4rJk1sAXmO+9hgu0 zs|&E;P>pyUFZp{&(dA(035AsQ#GHE7{%aHx-KpF5+$#-aa*kqibp`VVJ)L02*dto4 z>Y7gFSsHYu2WY?E$-rcPUVddDR8zEdy!^p>MT($XQiiO`?FmQ&uv&3ht|9*#5lGw_ zz&ibU0&e*E*dtC|M8MpkQ&{iN%NK(zuDlswKIpG50A?_An5w$h@v@e;7hMjvrpwSC zQCyZD6rGfJbFpmCRa|-9!MssFClC#1j7f9Ws-}8c1SBBEUA`O)0M2D?n##;u0aS&5 z+rc44QQK3u-5DPFxx{9b)I7@y0PXavY-?udyJ3zbyAU7C~1Bfi;{waz;d+N3Qq(?-sEWEPH&Xr(<>n8#_{Or5`Q|atQ z&xt@lMBZ@skGix~Gy`9mRRje3Sl+#T1Wm-{XW!IsTgEZd%u>ZnIG||M!J{G&c|QVp zNKX+Ap4{k`b6HAl4`p*vp|^jjOIr12!h92o;Dn;u!V0U+>e7}m%rpe0`~)0Gt;b|!9U-uF;kw{ z`CnoYSIU_0R26ls$J{A8DN|$W2Ji*Fa$r$Jqc6s!W!J5)o+!VY-b4TZpgg&AAqn&Y zLo<;Eio9V}>9uPC2^vs}n7>n0w@1ImX?tq>0TL9aXC5pvH;NyXD&O7ES>`2dB32=b zF~!~itS)I0%b>aN!h3~FfHR7c8%8}+{wzA?mi-u*U1ZFRWr}b|Q}kZ9E>^vy)e3;S z=$wz76-m{9oAF(pwnCJgpUT1Nx%--AzdZWum0y+U7I5|J=Fc!_ju}M2zr8P6% zlp|S!`sBlBx3p}M8{c~LFJE1=4&X~7Am9_Z*RB_f6M=h4ivh%#d94WO8|9qThlXv> z*E>%2>y|UQ;4^PtfvO=RIqjCzt^01VIgSL^Q8g$e5jU-$d-|b!vU6HD$!;J^^tScs z9kZ{>WhoAj(A+e$10u|jd+mO%SP>+&7(m1YB48|0&MPtAARhqo6IwnB66;MegA&X% z*c!cS&UJ&SbZ{?j76(w_*6MwHWO-`$h+;+Xb@M?0A}qI7e}>wxSG`uQ7?i)H`C%{` z8k{Fh6FfMmck-8oxM}I(Uev5V2(wHf0>(6J@8pqYU9xm1G@AzCWkbo=Xkbvx?sxn7~x*j z{NHBepYz2ogYr^9exKyIMa5Sx3oj6X7y_^`ew9d!bS=Ecbl)kkR&%BpkPnT4e#Mbr z<{sdNI=ArNfdIZRM`K7ISo#d$aP_5P!>MPljeL~} zeQhz0MWtL+^RV6h_nyH3&+vXZ;8~WGeM8JdJ^B+8sSk zk~PLL6U@WC=>$YrVn*@-z@p|i)%g(l%;;Sf@}bu#wZ`uI44~oOdK6pB#~3q`U$9O^ zcj|cV`7Ef9Eq3pcvc{*IOyiG+Yl|UY-8qz5*Y^-=L>Or8yMLZJMn7b? z`>x#p{^rRvg3;U7&oktE#R0CAg99^2-*p0+8M9sU?|#MZQwzvZe3_Hg!n{=Xr zN9}B7CZF)Mygg@=P2&KUN6DPVuLF2b z|9Kh`S)!@7BL{yf%S9yj?63!z=uH3-1xe)pv-D4WskqzI0l@WTlPAEus1PT}V{lLV zfYeo9ow2}VaLo=I4bT)X9qu?7a|NWI?Bv=(1CL%PR4axr4Yf)#=V&6qJ7Z8HO9qjVmN1o zzDB^md`|@Uk7Z!m$dZV_1hJYM&Y7X32zcN3L_pIlgHm5Hy9j*Z6LHR&p`+k~A^>Qb zeQ@f?fB-zv6MPZ+I)mTyISnwT*@qk+84!R+M9^q715*MbsGtG>fV|HeO(gI?eJt#3 z2)TuQSZbQt0+W1A06{|zPh`-WJ`{2egS(lLe4liH%T6$((VGPRhv~^D?iw`Y==wkp zpi4Pu@D?$>Vba2%%_MaW8ggV~U>2aK2tI4@7Qu8P$YFu5LC>3a!T6d_U4n)l-QX*E^BVzNa)Nh=dAvb~&k~@h;OBl%@}-h4(>05m@Pkt)^0LYtOe=>lvdXdk9BZFV?UGn{209Qo7V?CU-3xW_C{CxVazMdby z7XSd{eG>96fH58;U-A#~D~tdDAa{DmXkwmR&Y4fD#BxvD3&-~QOI|9B006)_yGdg;f6PaP z6ToF5%x-9R<+|DlL7fR`3efEUf&o55z)3;0NmBWH`-l@M2q1s}0tg_000Qtc{6GKU VY3s|)Bmw{c002ovPDHLkV1f{0-?;z) literal 0 HcmV?d00001 diff --git a/static/js/noble-secp256k1.min.js b/static/js/noble-secp256k1.min.js new file mode 100644 index 0000000..b4210cb --- /dev/null +++ b/static/js/noble-secp256k1.min.js @@ -0,0 +1,2 @@ +/*! noble-secp256k1 - MIT License (c) 2019 Paul Miller (paulmillr.com) */ +const B256=2n**256n,P=B256-0x1000003d1n,N=B256-0x14551231950b75fc4402da1732fc9bebfn,Gx=0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n,Gy=0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n,CURVE={p:P,n:N,a:0n,b:7n,Gx,Gy},fLen=32,crv=t=>mod(mod(t*t)*t+CURVE.b),err=(t="")=>{throw new Error(t)},big=t=>"bigint"==typeof t,str=t=>"string"==typeof t,fe=t=>big(t)&&0nbig(t)&&0nt instanceof Uint8Array||null!=t&&"object"==typeof t&&"Uint8Array"===t.constructor.name,au8=(t,n)=>!isu8(t)||"number"==typeof n&&n>0&&t.length!==n?err("Uint8Array expected"):t,u8n=t=>new Uint8Array(t),toU8=(t,n)=>au8(str(t)?h2b(t):u8n(au8(t)),n),mod=(t,n=P)=>{let e=t%n;return e>=0n?e:n+e},isPoint=t=>t instanceof Point?t:err("Point expected");class Point{constructor(t,n,e){this.px=t,this.py=n,this.pz=e}static fromAffine(t){return 0n===t.x&&0n===t.y?Point.ZERO:new Point(t.x,t.y,1n)}static fromHex(t){let n;const e=(t=toU8(t))[0],r=t.subarray(1),o=slcNum(r,0,32),i=t.length;if(33===i&&[2,3].includes(e)){fe(o)||err("Point hex invalid: x not FE");let t=sqrt(crv(o));!(1&~e)!==(1n===(1n&t))&&(t=mod(-t)),n=new Point(o,t,1n)}return 65===i&&4===e&&(n=new Point(o,slcNum(r,32,64),1n)),n?n.ok():err("Point is not on curve")}static fromPrivateKey(t){return G.mul(toPriv(t))}get x(){return this.aff().x}get y(){return this.aff().y}equals(t){const{px:n,py:e,pz:r}=this,{px:o,py:i,pz:s}=isPoint(t),a=mod(n*s),c=mod(o*r),d=mod(e*s),u=mod(i*r);return a===c&&d===u}negate(){return new Point(this.px,mod(-this.py),this.pz)}double(){return this.add(this)}add(t){const{px:n,py:e,pz:r}=this,{px:o,py:i,pz:s}=isPoint(t),{a,b:c}=CURVE;let d=0n,u=0n,m=0n;const h=mod(3n*c);let l=mod(n*o),y=mod(e*i),f=mod(r*s),p=mod(n+e),b=mod(o+i);p=mod(p*b),b=mod(l+y),p=mod(p-b),b=mod(n+r);let g=mod(o+s);return b=mod(b*g),g=mod(l+f),b=mod(b-g),g=mod(e+r),d=mod(i+s),g=mod(g*d),d=mod(y+f),g=mod(g-d),m=mod(a*b),d=mod(h*f),m=mod(d+m),d=mod(y-m),m=mod(y+m),u=mod(d*m),y=mod(l+l),y=mod(y+l),f=mod(a*f),b=mod(h*b),y=mod(y+f),f=mod(l-f),f=mod(a*f),b=mod(b+f),l=mod(y*b),u=mod(u+l),l=mod(g*b),d=mod(p*d),d=mod(d-l),l=mod(p*y),m=mod(g*m),m=mod(m+l),new Point(d,u,m)}mul(t,n=!0){if(!n&&0n===t)return I;if(ge(t)||err("invalid scalar"),this.equals(G))return wNAF(t).p;let e=I,r=G;for(let o=this;t>0n;o=o.double(),t>>=1n)1n&t?e=e.add(o):n&&(r=r.add(o));return e}mulAddQUns(t,n,e){return this.mul(n,!1).add(t.mul(e,!1)).ok()}toAffine(){const{px:t,py:n,pz:e}=this;if(this.equals(I))return{x:0n,y:0n};if(1n===e)return{x:t,y:n};const r=inv(e);return 1n!==mod(e*r)&&err("invalid inverse"),{x:mod(t*r),y:mod(n*r)}}assertValidity(){const{x:t,y:n}=this.aff();return fe(t)&&fe(n)||err("Point invalid: x or y"),mod(n*n)===crv(t)?this:err("Point invalid: not on curve")}multiply(t){return this.mul(t)}aff(){return this.toAffine()}ok(){return this.assertValidity()}toHex(t=!0){const{x:n,y:e}=this.aff();return(t?0n===(1n&e)?"02":"03":"04")+n2h(n)+(t?"":n2h(e))}toRawBytes(t=!0){return h2b(this.toHex(t))}}Point.BASE=new Point(Gx,Gy,1n),Point.ZERO=new Point(0n,1n,0n);const{BASE:G,ZERO:I}=Point,padh=(t,n)=>t.toString(16).padStart(n,"0"),b2h=t=>Array.from(t).map((t=>padh(t,2))).join(""),h2b=t=>{const n=t.length;(!str(t)||n%2)&&err("hex invalid 1");const e=u8n(n/2);for(let n=0;nBigInt("0x"+(b2h(t)||"0")),slcNum=(t,n,e)=>b2n(t.slice(n,e)),n2b=t=>big(t)&&t>=0n&&tb2h(n2b(t)),concatB=(...t)=>{const n=u8n(t.reduce(((t,n)=>t+au8(n).length),0));let e=0;return t.forEach((t=>{n.set(t,e),e+=t.length})),n},inv=(t,n=P)=>{(0n===t||n<=0n)&&err("no inverse n="+t+" mod="+n);let e=mod(t,n),r=n,o=0n,i=1n,s=1n,a=0n;for(;0n!==e;){const t=r/e,n=r%e,c=o-s*t,d=i-a*t;r=e,e=n,o=s,i=a,s=c,a=d}return 1n===r?mod(o,n):err("no inverse")},sqrt=t=>{let n=1n;for(let e=t,r=(P+1n)/4n;r>0n;r>>=1n)1n&r&&(n=n*e%P),e=e*e%P;return mod(n*n)===t?n:err("sqrt invalid")},toPriv=t=>(big(t)||(t=b2n(toU8(t,32))),ge(t)?t:err("private key out of range")),moreThanHalfN=t=>t>N>>1n,getPublicKey=(t,n=!0)=>Point.fromPrivateKey(t).toRawBytes(n);class Signature{constructor(t,n,e){this.r=t,this.s=n,this.recovery=e,this.assertValidity()}static fromCompact(t){return t=toU8(t,64),new Signature(slcNum(t,0,32),slcNum(t,32,64))}assertValidity(){return ge(this.r)&&ge(this.s)?this:err()}addRecoveryBit(t){return new Signature(this.r,this.s,t)}hasHighS(){return moreThanHalfN(this.s)}normalizeS(){return this.hasHighS()?new Signature(this.r,mod(this.s,N),this.recovery):this}recoverPublicKey(t){const{r:n,s:e,recovery:r}=this;[0,1,2,3].includes(r)||err("recovery id invalid");const o=bits2int_modN(toU8(t,32)),i=2===r||3===r?n+N:n;i>=P&&err("q.x invalid");const s=1&r?"03":"02",a=Point.fromHex(s+n2h(i)),c=inv(i,N),d=mod(-o*c,N),u=mod(e*c,N);return G.mulAddQUns(a,d,u)}toCompactRawBytes(){return h2b(this.toCompactHex())}toCompactHex(){return n2h(this.r)+n2h(this.s)}}const bits2int=t=>{const n=8*t.length-256,e=b2n(t);return n>0?e>>BigInt(n):e},bits2int_modN=t=>mod(bits2int(t),N),i2o=t=>n2b(t),cr=()=>"object"==typeof globalThis&&"crypto"in globalThis?globalThis.crypto:void 0;let _hmacSync;const optS={lowS:!0},optV={lowS:!0},prepSig=(t,n,e=optS)=>{["der","recovered","canonical"].some((t=>t in e))&&err("sign() legacy options not supported");let{lowS:r}=e;null==r&&(r=!0);const o=bits2int_modN(toU8(t)),i=i2o(o),s=toPriv(n),a=[i2o(s),i];let c=e.extraEntropy;if(c){!0===c&&(c=etc.randomBytes(32));const t=toU8(c);32!==t.length&&err(),a.push(t)}const d=o;return{seed:concatB(...a),k2sig:t=>{const n=bits2int(t);if(!ge(n))return;const e=inv(n,N),o=G.mul(n).aff(),i=mod(o.x,N);if(0n===i)return;const a=mod(e*mod(d+mod(s*i,N),N),N);if(0n===a)return;let c=a,u=(o.x===i?0:2)|Number(1n&o.y);return r&&moreThanHalfN(a)&&(c=mod(-a,N),u^=1),new Signature(i,c,u)}}};function hmacDrbg(t){let n=u8n(32),e=u8n(32),r=0;const o=()=>{n.fill(1),e.fill(0),r=0},i="drbg: tried 1000 values";if(t){const t=(...t)=>etc.hmacSha256Async(e,n,...t),s=async(r=u8n())=>{e=await t(u8n([0]),r),n=await t(),0!==r.length&&(e=await t(u8n([1]),r),n=await t())},a=async()=>(r++>=1e3&&err(i),n=await t(),n);return async(t,n)=>{let e;for(o(),await s(t);!(e=n(await a()));)await s();return o(),e}}{const t=(...t)=>{const r=_hmacSync;return r||err("etc.hmacSha256Sync not set"),r(e,n,...t)},s=(r=u8n())=>{e=t(u8n([0]),r),n=t(),0!==r.length&&(e=t(u8n([1]),r),n=t())},a=()=>(r++>=1e3&&err(i),n=t(),n);return(t,n)=>{let e;for(o(),s(t);!(e=n(a()));)s();return o(),e}}}const signAsync=async(t,n,e=optS)=>{const{seed:r,k2sig:o}=prepSig(t,n,e);return hmacDrbg(!0)(r,o)},sign=(t,n,e=optS)=>{const{seed:r,k2sig:o}=prepSig(t,n,e);return hmacDrbg(!1)(r,o)},verify=(t,n,e,r=optV)=>{let o,i,s,{lowS:a}=r;null==a&&(a=!0),"strict"in r&&err("verify() legacy options not supported");const c=t&&"object"==typeof t&&"r"in t;c||64===toU8(t).length||err("signature must be 64 bytes");try{o=c?new Signature(t.r,t.s).assertValidity():Signature.fromCompact(t),i=bits2int_modN(toU8(n)),s=e instanceof Point?e.ok():Point.fromHex(e)}catch(t){return!1}if(!o)return!1;const{r:d,s:u}=o;if(a&&moreThanHalfN(u))return!1;let m;try{const t=inv(u,N),n=mod(i*t,N),e=mod(d*t,N);m=G.mulAddQUns(s,n,e).aff()}catch(t){return!1}if(!m)return!1;return mod(m.x,N)===d},getSharedSecret=(t,n,e=!0)=>Point.fromHex(n).mul(toPriv(t)).toRawBytes(e),hashToPrivateKey=t=>{((t=toU8(t)).length<40||t.length>1024)&&err("expected proper params");const n=mod(b2n(t),N-1n)+1n;return n2b(n)},etc={hexToBytes:h2b,bytesToHex:b2h,concatBytes:concatB,bytesToNumberBE:b2n,numberToBytesBE:n2b,mod,invert:inv,hmacSha256Async:async(t,...n)=>{const e=cr(),r=e&&e.subtle;if(!r)return err("etc.hmacSha256Async not set");const o=await r.importKey("raw",t,{name:"HMAC",hash:{name:"SHA-256"}},!1,["sign"]);return u8n(await r.sign("HMAC",o,concatB(...n)))},hmacSha256Sync:_hmacSync,hashToPrivateKey,randomBytes:(t=32)=>{const n=cr();return n&&n.getRandomValues||err("crypto.getRandomValues must be defined"),n.getRandomValues(u8n(t))}},utils={normPrivateKeyToScalar:toPriv,isValidPrivateKey:t=>{try{return!!toPriv(t)}catch(t){return!1}},randomPrivateKey:()=>hashToPrivateKey(etc.randomBytes(48)),precompute:(t=8,n=G)=>(n.multiply(3n),n)};Object.defineProperties(etc,{hmacSha256Sync:{configurable:!1,get:()=>_hmacSync,set(t){_hmacSync||(_hmacSync=t)}}});const W=8,precompute=()=>{const t=[];let n=G,e=n;for(let r=0;r<33;r++){e=n,t.push(e);for(let r=1;r<128;r++)e=e.add(n),t.push(e);n=e.double()}return t};let Gpows;const wNAF=t=>{const n=Gpows||(Gpows=precompute()),e=(t,n)=>{let e=n.negate();return t?e:n};let r=I,o=G;const i=BigInt(255),s=BigInt(8);for(let a=0;a<33;a++){const c=128*a;let d=Number(t&i);t>>=s,d>128&&(d-=256,t+=1n);const u=c,m=c+Math.abs(d)-1,h=a%2!=0,l=d<0;0===d?o=o.add(e(h,n[u])):r=r.add(e(l,n[m]))}return{p:r,f:o}};export{getPublicKey,sign,signAsync,verify,CURVE,getSharedSecret,etc,utils,Point as ProjectivePoint,Signature}; diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..acd81a6 --- /dev/null +++ b/tasks.py @@ -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)) diff --git a/templates/nwcprovider/admin.html b/templates/nwcprovider/admin.html new file mode 100644 index 0000000..dbda4a4 --- /dev/null +++ b/templates/nwcprovider/admin.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +
+
+
NWC Service Provider - Config
+
+
+ + +
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} \ No newline at end of file diff --git a/templates/nwcprovider/index.html b/templates/nwcprovider/index.html new file mode 100644 index 0000000..a794dc2 --- /dev/null +++ b/templates/nwcprovider/index.html @@ -0,0 +1,659 @@ + + + + +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + + + + + + + + + + +
+
+
Connected Apps
+
+ + + +
+ + + + + ${ col.label } + + + + + + + + +
+
+
+ +
+ + +
{{SITE_TITLE}} NWC Service provider extension
+

+ An extension that turns LNBits into a Nostr Wallet Connect service provider. +

+
+ + + + + + + Swagger API + + + + + Settings + + + + +
+
+ + + + + + + + + +

Info

+ + + + Description + ${connectionInfoDialog.data.description} + + + Last used + ${connectionInfoDialog.data.last_used} + + + Expires + ${connectionInfoDialog.data.expires_at} + + + Created + ${connectionInfoDialog.data.created_at} + + + + Permissions + + + ${connectionInfoDialog.data.permissions} + + + + + Limits + + + + + Budget (sats) +
used/max
+ + +
+ Refresh +
+ + + + No limits + + + ${budget.used_budget_sats} / ${budget.budget_sats} + ${budget.refresh_window} + + +
+ + +
+
+
+ + + + Close + + +
+ +
+ + + + + + + + + + + +

Please scan this QR code with a supported app

+

Connect only with app you trust!

+ +
+ + + + + +
+
+ + + + + + +

Pairing

+

Complete the last step of the setup by pasting or scanning your connection's + pairing secret in the desired + app to + finalise the connection.

+

Connect only with app you trust!

+ +
+ + + + +
Advanced
+ +
+ + + + + + + + + + + Close + +
+
+ + + +

Add connection

+ + + +
+ + +
+ + + Authorize the app to + + + + + + + + Limit the spendable amount + + + No limit + + + + + + + + + +
+ Connect +
+ Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} \ No newline at end of file diff --git a/toc.md b/toc.md new file mode 100644 index 0000000..fc97b10 --- /dev/null +++ b/toc.md @@ -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]. diff --git a/views.py b/views.py new file mode 100644 index 0000000..0adf7f4 --- /dev/null +++ b/views.py @@ -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 index(request: Request, user: User = Depends(check_admin)): + return nwcprovider_renderer().TemplateResponse( + "nwcprovider/admin.html", {"request": request, "user": user.dict()} + ) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..5e18380 --- /dev/null +++ b/views_api.py @@ -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)