diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index 53ad44d..39953c7 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -1,5 +1,6 @@ #!/bin/bash CONTAINER_WORKSPACE_FOLDER=$1 +cd $CONTAINER_WORKSPACE_FOLDER sudo apt update -y sudo apt install -y python3.9-distutils curl sudo apt-get install -y docker.io @@ -8,9 +9,8 @@ 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 if [ ! -d lnbits ] ; then - sudo git clone https://github.com/lnbits/lnbits.git; + 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..72a38ff --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Tests + +on: + push: + pull_request: + + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.9' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install poetry + + - name: Run setup script + run: /bin/bash .devcontainer/setup.sh ${{ github.workspace }} + + - name: Run unit tests + run: pytest tests/unit/*.py -s + + - name: Setup integration tests + run: bash tests/integration/start.sh + + - name: Run integration tests + run: pytest tests/integration/test_all.py -s \ No newline at end of file diff --git a/.gitignore b/.gitignore index bee8a64..3f09e69 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ __pycache__ +lnbits +tests/integration/strfry-data +tests/integration/lnbits_itest_data \ No newline at end of file diff --git a/tests/integration/.env b/tests/integration/.env new file mode 100644 index 0000000..0f1cedc --- /dev/null +++ b/tests/integration/.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=true +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/tests/integration/.v039fk_lnbits_integration_test_folder b/tests/integration/.v039fk_lnbits_integration_test_folder new file mode 100644 index 0000000..3e4aa3c --- /dev/null +++ b/tests/integration/.v039fk_lnbits_integration_test_folder @@ -0,0 +1 @@ +yes v039fk_lnbits_integration_test_folder \ No newline at end of file diff --git a/tests/integration/data.zip b/tests/integration/data.zip new file mode 100644 index 0000000..e89b53e Binary files /dev/null and b/tests/integration/data.zip differ diff --git a/tests/integration/start.sh b/tests/integration/start.sh new file mode 100644 index 0000000..dd68b60 --- /dev/null +++ b/tests/integration/start.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# cd in the script folder +cd "$(dirname "$0")" + +# Check if we are in the right folder +if [ ! -f ".v039fk_lnbits_integration_test_folder" ]; then + echo "Please run this script from the tests/integration folder" + exit 1 +fi + +# Double check if we are in the right folder +if [ "`cat .v039fk_lnbits_integration_test_folder`" != "yes v039fk_lnbits_integration_test_folder" ]; then + echo "Please run this script from the tests/integration folder!" + exit 1 +fi + +# Start nostr Relay +docker run --name=lnbits_nwcprovider_ext_nostr_test \ +-d \ +--rm \ +-v $PWD/strfry.conf:/etc/strfry.conf \ +-v $PWD/strfry-data:/app/strfry-db \ +-p 7777:7777 \ +ghcr.io/hoytech/strfry:latest + +# Start lnbits with the nwcprovider extension +rm -Rf lnbits_itest_data +unzip data.zip +docker run --name=lnbits_nwcprovider_ext_lnbits_test \ +-d \ +--rm \ +-p 5002:5000 \ +-v ${PWD}/.env:/app/.env \ +-v ${PWD}/lnbits_itest_data/:/app/data \ +-v ${PWD}/../../:/app/lnbits/extensions/nwcprovider:ro \ +lnbits/lnbits + + +docker network create lnbits_nwcprovider_ext_test_network || true +docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_nostr_test --alias nostr|| true +docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_lnbits_test --alias lnbits|| true + + diff --git a/tests/integration/stop.sh b/tests/integration/stop.sh new file mode 100644 index 0000000..6da1e19 --- /dev/null +++ b/tests/integration/stop.sh @@ -0,0 +1,6 @@ +#!/bin/bash +docker stop lnbits_nwcprovider_ext_nostr_test || true +docker stop lnbits_nwcprovider_ext_lnbits_test || true + +# Remove lnbits_nwcprovider_ext_test_network network +docker network rm lnbits_nwcprovider_ext_test_network || true \ No newline at end of file diff --git a/tests/integration/strfry.conf b/tests/integration/strfry.conf new file mode 100644 index 0000000..150bcc3 --- /dev/null +++ b/tests/integration/strfry.conf @@ -0,0 +1,138 @@ +## +## Default strfry config +## + +# Directory that contains the strfry LMDB database (restart required) +db = "./strfry-db/" + +dbParams { + # Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required) + maxreaders = 256 + + # Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required) + mapsize = 10995116277760 + + # Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required) + noReadAhead = false +} + +events { + # Maximum size of normalised JSON, in bytes + maxEventSize = 65536 + + # Events newer than this will be rejected + rejectEventsNewerThanSeconds = 900 + + # Events older than this will be rejected + rejectEventsOlderThanSeconds = 94608000 + + # Ephemeral events older than this will be rejected + rejectEphemeralEventsOlderThanSeconds = 60 + + # Ephemeral events will be deleted from the DB when older than this + ephemeralEventsLifetimeSeconds = 300 + + # Maximum number of tags allowed + maxNumTags = 2000 + + # Maximum size for tag values, in bytes + maxTagValSize = 1024 +} + +relay { + # Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required) + bind = "0.0.0.0" + + # Port to open for the nostr websocket protocol (restart required) + port = 7777 + + # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required) + nofiles = 1000000 + + # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case) + realIpHeader = "" + + info { + # NIP-11: Name of this server. Short/descriptive (< 30 characters) + name = "strfry default" + + # NIP-11: Detailed information about relay, free-form + description = "This is a strfry instance." + + # NIP-11: Administrative nostr pubkey, for contact purposes + pubkey = "" + + # NIP-11: Alternative administrative contact (email, website, etc) + contact = "" + } + + # Maximum accepted incoming websocket frame size (should be larger than max event) (restart required) + maxWebsocketPayloadSize = 131072 + + # Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required) + autoPingSeconds = 55 + + # If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy) + enableTcpKeepalive = false + + # How much uninterrupted CPU time a REQ query should get during its DB scan + queryTimesliceBudgetMicroseconds = 10000 + + # Maximum records that can be returned per filter + maxFilterLimit = 500 + + # Maximum number of subscriptions (concurrent REQs) a connection can have open at any time + maxSubsPerConnection = 20 + + writePolicy { + # If non-empty, path to an executable script that implements the writePolicy plugin logic + plugin = "" + } + + compression { + # Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required) + enabled = true + + # Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required) + slidingWindow = true + } + + logging { + # Dump all incoming messages + dumpInAll = false + + # Dump all incoming EVENT messages + dumpInEvents = false + + # Dump all incoming REQ/CLOSE messages + dumpInReqs = false + + # Log performance metrics for initial REQ database scans + dbScanPerf = false + + # Log reason for invalid event rejection? Can be disabled to silence excessive logging + invalidEvents = true + } + + numThreads { + # Ingester threads: route incoming requests, validate events/sigs (restart required) + ingester = 3 + + # reqWorker threads: Handle initial DB scan for events (restart required) + reqWorker = 3 + + # reqMonitor threads: Handle filtering of new events (restart required) + reqMonitor = 3 + + # negentropy threads: Handle negentropy protocol messages (restart required) + negentropy = 2 + } + + negentropy { + # Support negentropy protocol messages + enabled = true + + # Maximum records that sync will process before returning an error + maxSyncEvents = 1000000 + } +} \ No newline at end of file diff --git a/tests/integration/test_all.py b/tests/integration/test_all.py new file mode 100644 index 0000000..d07d88a --- /dev/null +++ b/tests/integration/test_all.py @@ -0,0 +1,838 @@ +import pytest +import asyncio +from loguru import logger +import httpx +import asyncio +import bolt11 +import secp256k1 +import time +from typing import List, Dict +import websockets +import random +import json +from typing import List, Dict, Optional +from Cryptodome.Util.Padding import pad, unpad +import hashlib +from Cryptodome import Random +from Cryptodome.Cipher import AES +import base64 +from typing import Union, Dict +wallets = { + "wallet1": { + "name": "wallet1", + "id": "ca464af8b1a94f988d6d729586961d2a", + "admin_key": "7d2541d0c4154a498e43e5e287c64640", + "balance_msats": 1000000, + }, + "wallet2": { + "name": "wallet2", + "id": "147adb7b35f14fcca146a5e9b570fc18", + "admin_key": "4d67c02489f34cd78aec68af48c7c8b4", + "balance_msats": 1000000, + }, + "wallet3": { + "name": "wallet3", + "id": "343a5d4a96cc4cf793a49a2df9ca04e6", + "admin_key": "ca4e7c921fdb4ec2b761cadb5fd1d30d", + "balance_msats": 1000000, + }, + "wallet4": { + "name": "wallet4", + "id": "2faa91184177414ab14712cadafbc78f", + "admin_key": "0ffd65580a664e0aae85687f99dac7ad", + "balance_msats": 1000000, + } +} + +async def check_services(): + # wait for http server in localhost:7777 + while True: + try: + async with httpx.AsyncClient() as client: + resp = await client.get('http://localhost:7777') + assert resp.status_code == 200 + break + except: + logger.info("Waiting for nostr relay @ http://localhost:7777") + logger.info("Please start the required services by running `bash start.sh` if you haven't already") + await asyncio.sleep(1) + + # wait lnbits @ localhost:5000 + while True: + try: + async with httpx.AsyncClient() as client: + resp = await client.get('http://localhost:5002') + assert resp.status_code == 200 + break + except: + logger.info("Waiting for lnbits @ http://localhost:5002") + logger.info("Please start the required services by running `bash start.sh` if you haven't already") + await asyncio.sleep(1) + + +async def get_wallet_balance(w:str): + api_key = wallets[w]["admin_key"] + async with httpx.AsyncClient() as client: + resp = await client.get(f'http://localhost:5002/api/v1/wallet?api-key={api_key}') + assert resp.status_code == 200 + v = resp.json() + balance = v["balance"] + return balance + +async def refresh_wallet_balances(): + for w in wallets: + wallets[w]["balance_msats"] = await get_wallet_balance(w) + logger.info(f"{w} balance: {wallets[w]['balance_msats']}") + + +def gen_keypair(): + private_key_hex = bytes.hex(secp256k1._gen_private_key()) + private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex)) + public_key = private_key.pubkey + public_key_hex = public_key.serialize().hex()[2:] + return { + "priv": private_key_hex, + "pub": public_key_hex + } + +async def create_nwc(w:str, desc:str, permissions:List[str], budgets:List[Dict[str, int]], expiration: 0): + keypair = gen_keypair() + api_key = wallets[w]["admin_key"] + async with httpx.AsyncClient() as client: + resp = await client.put(f'http://localhost:5002/nwcprovider/api/v1/nwc/{keypair["pub"]}?api-key={api_key}', json={ + "permissions": permissions, + "description": desc, + "expires_at": time.time()+expiration if expiration > 0 else 0, + "budgets": budgets + }) + assert resp.status_code == 201 + nwc = resp.json() + + async with httpx.AsyncClient() as client: + resp = await client.get(f'http://localhost:5002/nwcprovider/api/v1/pairing/{keypair["priv"]}') + assert resp.status_code == 200 + pairing = resp.json() + return { + "pubkey": keypair["pub"], + "privkey": keypair["priv"], + "pairing": pairing, + "nwc": nwc + } + + + +async def delete_nwc(w:str, pubkey:str): + + api_key = wallets[w]["admin_key"] + async with httpx.AsyncClient() as client: + resp = await client.delete(f'http://localhost:5002/nwcprovider/api/v1/nwc/{pubkey}?api-key={api_key}') + assert resp.status_code == 200 + return resp.json() + + +class NWCWallet : + def __init__(self, pairing_url): + # Extract from Pairing url nostr+walletconnect://provider_pub?relay=relay&secret=secret + self.pairing_url = pairing_url + self.provider_pub_hex = pairing_url.split("://")[1].split("?")[0] + self.relay = pairing_url.split("relay=")[1].split("&")[0] + self.secret = pairing_url.split("secret=")[1] + self.ws = None + self.connected = False + self.shutdown = False + self.event_queue = [] + self.subscriptions_count = 0 + self.sub_id="" + self.private_key = secp256k1.PrivateKey(bytes.fromhex(self.secret)) + self.private_key_hex = self.secret + self.public_key = self.private_key.pubkey + self.public_key_hex = self.public_key.serialize().hex()[2:] + + + async def close(self): + self.shutdown = True + await self.ws.close() + self.connected = False + + async def _wait_for_connection(self): + while not self.connected: + await asyncio.sleep(0.2) + + async def start(self): + asyncio.create_task(self._run()) + await self._wait_for_connection() + + + + def _is_shutting_down(self): + return self.shutdown + + def _get_new_subid(self) -> str: + subid = "lnbitsnwcstest"+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 _run(self): + while True: + try: + async with websockets.connect(self.relay) as ws: + self.ws = ws + self.connected = True + self.sub_id = self._get_new_subid() + res_filter = { + "kinds": [23195], + "authors": [self.provider_pub_hex], + "since": int(time.time()) + } + await self.ws.send(self._json_dumps(["REQ", self.sub_id, res_filter])) + while not self._is_shutting_down() and not ws.closed: + try: + reply = await ws.recv() + try: + await self._on_message(ws, reply) + except Exception as e: + pass + except Exception as e: + logger.debug("Error receiving message: " + str(e)) + break + except Exception as e: + logger.debug("Error connecting to relay: " + str(e)) + pass + self.connected = False + if not self._is_shutting_down(): + await asyncio.sleep(0.2) + else: + break + + def _encrypt_content(self, content: str, pubkey_hex: str, iv_seed: Optional[int] = None) -> str: + pubkey = secp256k1.PublicKey( + bytes.fromhex("02" + pubkey_hex), True) + shared = pubkey.tweak_mul(bytes.fromhex( + self.private_key_hex)).serialize()[1:] + if not iv_seed: + iv = Random.new().read(AES.block_size) + else: + iv = hashlib.sha256(iv_seed.to_bytes(32, byteorder='big')).digest() + iv = iv[:AES.block_size] + aes = AES.new(shared, AES.MODE_CBC, iv) + content_bytes = content.encode("utf-8") + content_bytes = pad(content_bytes, AES.block_size) + encrypted_b64 = base64.b64encode( + aes.encrypt(content_bytes)).decode("ascii") + ivB64 = base64.b64encode(iv).decode("ascii") + encrypted_content = encrypted_b64 + "?iv=" + ivB64 + return encrypted_content + + def _decrypt_content(self, content: str, pubkey_hex: str) -> str: + pubkey = secp256k1.PublicKey( + bytes.fromhex("02" + pubkey_hex), True) + shared = pubkey.tweak_mul(bytes.fromhex( + self.private_key_hex)).serialize()[1:] + (encrypted_content_b64, iv_b64) = content.split("?iv=") + encrypted_content = base64.b64decode( + encrypted_content_b64.encode("ascii")) + iv = base64.b64decode(iv_b64.encode("ascii")) + aes = AES.new(shared, AES.MODE_CBC, iv) + decrypted_bytes = aes.decrypt(encrypted_content) + decrypted_bytes = unpad(decrypted_bytes, AES.block_size) + decrypted = decrypted_bytes.decode("utf-8") + return decrypted + + async def _on_message(self, ws, message: str): + logger.debug("Received message: " + message) + msg = json.loads(message) + if msg[0] == "EVENT": # Event message + sub_id = msg[1] + event = msg[2] + nwc_pubkey = event["pubkey"] + content = self._decrypt_content(event["content"], nwc_pubkey) + content = json.loads(content) + self.event_queue.append({ + "created_at": event["created_at"], + "content": content, + "result": content["result"] if "result" in content else None, + "error": content["error"] if "error" in content else None, + "method": content["result_type"], + "tags": event["tags"] + }) + + def _json_dumps(self, data: Union[Dict, list]) -> str: + 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 _sign_event(self, event: Dict) -> Dict: + 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 sendEvent(self, method,params): + await self._wait_for_connection() + event = { + "created_at": int(time.time()), + "kind": 23194, + "tags":[ + ["p", self.provider_pub_hex], + ], + "content": json.dumps({ + "method": method, + "params": params + + }) + } + logger.debug("Sending event: " + str(event)) + event["content"] = self._encrypt_content(event["content"], self.provider_pub_hex) + self._sign_event(event) + logger.debug("Sending event (encrypted): " + str(event)) + await self.ws.send(self._json_dumps(["EVENT", event])) + + async def waitFor(self, result_type, callback=None, on_error_callback=None, timeout=10): + now = time.time() + while True: + for i in range(len(self.event_queue)): + e = self.event_queue[i] + event_time = e["created_at"] + if e["method"] == result_type: + if event_time > now - timeout: + if not callback or callback(e["result"], e["tags"]): + self.event_queue.pop(i) + if e["error"]: + if on_error_callback: + on_error_callback(e["error"], e["tags"]) + + return e["result"], e["tags"], e["error"] + else: + return e["result"], e["tags"], None + await asyncio.sleep(1) + if timeout > 0 and time.time() > now + timeout: + raise Exception("Timeout") + + + +@pytest.mark.asyncio +async def test_create(): + await check_services() + nwc = await create_nwc("wallet1", "test_create", ["pay"], [], 0) + logger.info(nwc) + assert nwc["nwc"]["data"]["expires_at"] == 0 + assert nwc["nwc"]["data"]["permissions"] == "pay" + assert nwc["nwc"]["data"]["description"] == "test_create" + assert nwc["nwc"]["data"]["last_used"] > time.time() - 10 + assert nwc["nwc"]["data"]["last_used"] < time.time() + 10 + assert nwc["nwc"]["data"]["created_at"] > time.time() - 10 + assert nwc["nwc"]["data"]["created_at"] < time.time() + 10 + assert len(nwc["nwc"]["budgets"]) == 0 + + +@pytest.mark.asyncio +async def test_make_invoice(): + await check_services() + nwc = await create_nwc("wallet1", "test_make_invoice", ["invoice"], [], 0) + wallet1 = NWCWallet(nwc["pairing"]) + await wallet1.start() + await wallet1.sendEvent("make_invoice", {"amount": 1, "description": "test 123", "expiry": 1000}) + result, tags, error = await wallet1.waitFor("make_invoice") + logger.info(error) + assert error , "Expected internal error, because amount is too low" + + await wallet1.sendEvent("make_invoice", {"amount": 123000, "description":"test 123", "expiry": 1000}) + result, tags, error = await wallet1.waitFor("make_invoice") + assert not error + assert result["type"] == "incoming" + assert result["description"] == "test 123" + assert result["amount"] == 123000 + assert result["preimage"] + assert result["created_at"] < time.time() + 10 + assert result["created_at"] > time.time() - 10 + assert result["expires_at"] < time.time() + 1000 + 10 + assert result["expires_at"] > time.time() + assert result["invoice"] + + invoice = result["invoice"] + decoded_invoice = bolt11.decode(invoice) + assert decoded_invoice.amount_msat == 123000 + + await wallet1.close() + + +@pytest.mark.asyncio +async def test_lookup_invoice(): + await check_services() + nwc = await create_nwc("wallet1", "test_lookup_invoice_make", ["invoice"], [], 0) + nwc2 = await create_nwc("wallet1", "test_lookup_invoice_lookup", ["lookup"], [], 0) + + wallet1 = NWCWallet(nwc["pairing"]) + await wallet1.start() + + + await wallet1.sendEvent("make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}) + result, tags, error = await wallet1.waitFor("make_invoice") + assert not error + assert result["type"] == "incoming" + assert result["description"] == "test 123" + assert result["amount"] == 123000 + assert result["preimage"] + assert result["created_at"] < time.time() + 10 + assert result["created_at"] > time.time() - 10 + assert result["expires_at"] < time.time() + 1000 + 10 + assert result["expires_at"] > time.time() + assert result["invoice"] + + wallet2 = NWCWallet(nwc2["pairing"]) + await wallet2.start() + + await wallet2.sendEvent("lookup_invoice", {"invoice": result["invoice"]}) + result, tags, error = await wallet2.waitFor("lookup_invoice") + assert not error + assert result["type"] == "incoming" + assert result["description"] == "test 123" + assert result["amount"] == 123000 + assert result["preimage"] + assert result["created_at"] < time.time() + 10 + assert result["created_at"] > time.time() - 10 + assert result["expires_at"] < time.time() + 1000 + 10 + assert result["expires_at"] > time.time() + assert result["invoice"] + + await wallet1.close() + await wallet2.close() + + +@pytest.mark.asyncio +async def test_get_info(): + await check_services() + nwc = await create_nwc("wallet1", "test_get_info", ["info"], [], 0) + + + wallet1 = NWCWallet(nwc["pairing"]) + await wallet1.start() + + + await wallet1.sendEvent("get_info", {}) + result, tags, error = await wallet1.waitFor("get_info") + assert not error + assert result["alias"] == "LNBits_NWC_SP" + assert result["color"] == "" + assert result["network"] == "mainnet" + assert result["block_height"] == 0 + assert result["block_hash"] == "" + assert result["methods"] == ["get_info"] + + await wallet1.close() + + +@pytest.mark.asyncio +async def test_permisions(): + await check_services() + nwc = await create_nwc("wallet1", "test_permisions1", ["info"], [], 0) + nwc2 = await create_nwc("wallet1", "test_permisions2", [ "pay", "invoice"], [], 0) + nwc3 = await create_nwc("wallet1", "test_permisions3", ["info" , "pay", "invoice"], [], 0) + + + wallet1 = NWCWallet(nwc["pairing"]) + wallet2 = NWCWallet(nwc2["pairing"]) + wallet3 = NWCWallet(nwc3["pairing"]) + await wallet1.start() + + + await wallet1.sendEvent("get_info", {}) + result, tags, error = await wallet1.waitFor("get_info") + assert not error + + await wallet1.sendEvent("make_invoice", { + "amount": 123000, + "description": "test 123", + "expiry": 1000 + }) + result, tags, error = await wallet1.waitFor("make_invoice") + assert error + + await wallet1.close() + await wallet2.start() + + await wallet2.sendEvent("get_info", {}) + result, tags, error = await wallet2.waitFor("get_info") + assert error + + + await wallet2.sendEvent("make_invoice", { + "amount": 123000, + "description": "test 123", + "expiry": 1000 + }) + result, tags, error = await wallet2.waitFor("make_invoice") + assert not error + + await wallet2.close() + await wallet3.start() + + await wallet3.sendEvent("get_info", {}) + result, tags, error = await wallet3.waitFor("get_info") + assert not error + assert "make_invoice" in result["methods"] + assert "pay_invoice" in result["methods"] + assert "get_info" in result["methods"] + + await wallet3.close() + + +@pytest.mark.asyncio +async def test_pay_invoice_and_balance(): + await check_services() + nwc = await create_nwc("wallet1", "test_pay_invoice_and_balance", ["invoice", "balance"], [], 0) + nwc2 = await create_nwc("wallet2", "test_pay_invoice_and_balance", ["pay", "balance"], [], 0) + + + wallet1 = NWCWallet(nwc["pairing"]) + await wallet1.start() + + + await refresh_wallet_balances() + wallet1_balance = wallets["wallet1"]["balance_msats"] + wallet2_balance = wallets["wallet2"]["balance_msats"] + + + await wallet1.sendEvent("make_invoice", { + "amount": 123000, + "description": "test 123" + }) + + result, tags, error = await wallet1.waitFor("make_invoice") + assert not error + assert result["invoice"] + + invoice = result["invoice"] + wallet2 = NWCWallet(nwc2["pairing"]) + await wallet2.start() + + await wallet2.sendEvent("pay_invoice", { + "invoice": invoice + }) + result, tags, error = await wallet2.waitFor("pay_invoice") + assert not error + assert result["preimage"] + + await refresh_wallet_balances() + wallet1_balance_new = wallets["wallet1"]["balance_msats"] + wallet2_balance_new = wallets["wallet2"]["balance_msats"] + + assert wallet1_balance_new == wallet1_balance + 123000 + assert wallet2_balance_new == wallet2_balance - 123000 + + await wallet1.sendEvent("get_balance", {}) + result, tags, error = await wallet1.waitFor("get_balance") + assert not error + assert result["balance"] == wallet1_balance_new + + await wallet2.sendEvent("get_balance", {}) + result, tags, error = await wallet2.waitFor("get_balance") + assert not error + assert result["balance"] == wallet2_balance_new + + await wallet1.close() + await wallet2.close() + + +@pytest.mark.asyncio +async def test_multi_pay_invoices(): + nwc1 = await create_nwc("wallet1", "test_multi_pay_invoices", ["invoice", "pay", "balance"], [], 0) + nwc2 = await create_nwc("wallet2", "test_multi_pay_invoices", ["invoice", "pay", "balance"], [], 0) + nwc3 = await create_nwc("wallet3", "test_multi_pay_invoices", ["invoice", "pay", "balance"], [], 0) + + wallet1 = NWCWallet(nwc1["pairing"]) + wallet2 = NWCWallet(nwc2["pairing"]) + wallet3 = NWCWallet(nwc3["pairing"]) + + await wallet1.start() + await wallet2.start() + await wallet3.start() + + await refresh_wallet_balances() + wallet1_balance = wallets["wallet1"]["balance_msats"] + wallet2_balance = wallets["wallet2"]["balance_msats"] + wallet3_balance = wallets["wallet3"]["balance_msats"] + + await wallet1.sendEvent("make_invoice", { + "amount": 123000, + "description": "test 123" + }) + + result, tags, error = await wallet1.waitFor("make_invoice") + assert not error + assert result["invoice"] + invoice1 = result["invoice"] + + await wallet1.sendEvent("make_invoice", { + "amount": 123000, + "description": "test 123" + }) + result, tags, error = await wallet1.waitFor("make_invoice") + assert not error + assert result["invoice"] + invoice2 = result["invoice"] + + await wallet2.sendEvent("make_invoice", { + "amount": 123000, + "description": "test 123" + }) + result, tags, error = await wallet2.waitFor("make_invoice") + assert not error + assert result["invoice"] + invoice3 = result["invoice"] + + await wallet3.sendEvent("multi_pay_invoice", { + "invoices":[ + {"id":"invoice1", "invoice": invoice1, "amount": 123000}, + {"id":"invoice2", "invoice": invoice2, "amount": 123000}, + { "invoice": invoice3} + ] + }) + result, tags, error = await wallet3.waitFor("multi_pay_invoice") + assert not error + d_tag = [t[1] for t in tags if t[0] == "d"][0] + if d_tag=="invoice1": + assert result["preimage"] + elif d_tag=="invoice2": + assert result["preimage"] + elif d_tag==invoice3: + assert result["preimage"] + else: + assert False + + + await refresh_wallet_balances() + wallet1_balance_new = wallets["wallet1"]["balance_msats"] + wallet2_balance_new = wallets["wallet2"]["balance_msats"] + wallet3_balance_new = wallets["wallet3"]["balance_msats"] + + assert wallet1_balance_new == wallet1_balance + 123000 + 123000 + assert wallet2_balance_new == wallet2_balance + 123000 + assert wallet3_balance_new == wallet3_balance - 123000 - 123000 - 123000 + + + + await wallet1.sendEvent("get_balance", {}) + result, tags, error = await wallet1.waitFor("get_balance") + assert not error + assert result["balance"] == wallet1_balance_new + + await wallet2.sendEvent("get_balance", {}) + result, tags, error = await wallet2.waitFor("get_balance") + assert not error + assert result["balance"] == wallet2_balance_new + + await wallet3.sendEvent("get_balance", {}) + result, tags, error = await wallet3.waitFor("get_balance") + assert not error + assert result["balance"] == wallet3_balance_new + + await wallet1.close() + await wallet2.close() + await wallet3.close() + + + + + + +@pytest.mark.asyncio +async def test_insufficient_balance(): + nwc1 = await create_nwc("wallet1", "test_insufficient_balance", ["invoice", "pay", "balance"], [], 0) + nwc2 = await create_nwc("wallet2", "test_insufficient_balance", ["invoice", "pay", "balance"], [], 0) + await refresh_wallet_balances() + wallet1_balance = wallets["wallet1"]["balance_msats"] + amount_to_spend = wallet1_balance + 1000 + wallet1 = NWCWallet(nwc1["pairing"]) + wallet2 = NWCWallet(nwc2["pairing"]) + await wallet1.start() + await wallet2.start() + + await wallet2.sendEvent("make_invoice", { + "amount": amount_to_spend, + "description": "test 123" + }) + result, tags, error = await wallet2.waitFor("make_invoice") + assert not error + assert result["invoice"] + invoice = result["invoice"] + + await wallet1.sendEvent("pay_invoice", { + "invoice": invoice + }) + result, tags, error = await wallet1.waitFor("pay_invoice") + logger.info(error) + logger.info(result) + logger.info(amount_to_spend) + + assert error + # The proper error code should be INSUFFICIENT_BALANCE + # but we use the more generic PAYMENT_FAILED in our implementation for simplicity + #assert error["code"] == "INSUFFICIENT_BALANCE" + assert error["code"] == "PAYMENT_FAILED" + + await wallet1.close() + await wallet2.close() + + + +@pytest.mark.asyncio +async def test_expiry(): + nwc = await create_nwc("wallet3", "test_expiry", ["invoice", "pay", "balance"], [], 1) + await asyncio.sleep(2) + wallet3 = NWCWallet(nwc["pairing"]) + await wallet3.start() + await wallet3.sendEvent("make_invoice", { + "amount": 123000, + "description": "test 123" + }) + result, tags, error = await wallet3.waitFor("make_invoice") + assert error + assert error["code"] == "UNAUTHORIZED" , "Expected UNAUTHORIZED error, because the NWC expired" + await wallet3.close() + + + + +@pytest.mark.asyncio +async def test_budget(): + nwc1 = await create_nwc("wallet1", "test_expiry", ["invoice", "pay", "balance"], [], 0) + nwc3 = await create_nwc("wallet3", "test_expiry", ["invoice", "pay", "balance"], [ + { + "budget_msats": 100000, + "refresh_window": 3600, + "created_at": time.time() + } + ], 0) + wallet1 = NWCWallet(nwc1["pairing"]) + wallet3 = NWCWallet(nwc3["pairing"]) + await wallet3.start() + await wallet1.start() + await wallet1.sendEvent("make_invoice", { + "amount": 101000, + "description": "Invalid" + }) + result, tags, error = await wallet1.waitFor("make_invoice") + assert not error + + await wallet3.sendEvent("pay_invoice", { + "invoice": result["invoice"] + }) + result, tags, error = await wallet3.waitFor("pay_invoice") + assert error + assert error["code"] == "QUOTA_EXCEEDED" , "Expected QUOTA_EXCEEDED error, because the budget was exceeded" + + await wallet1.sendEvent("make_invoice", { + "amount": 99000, + "description": "Valid" + }) + result, tags, error = await wallet1.waitFor("make_invoice") + assert not error + + await wallet3.sendEvent("pay_invoice", { + "invoice": result["invoice"] + }) + result, tags, error = await wallet3.waitFor("pay_invoice") + assert not error, "Expected successful payment, because the budget was not exceeded" + assert result["preimage"] + + await wallet1.sendEvent("make_invoice", { + "amount": 100000-99000+1000, + "description": "Invalid" + }) + + result, tags, error = await wallet1.waitFor("make_invoice") + assert not error + + await wallet3.sendEvent("pay_invoice", { + "invoice": result["invoice"] + }) + result, tags, error = await wallet3.waitFor("pay_invoice") + assert error + assert error["code"] == "QUOTA_EXCEEDED" , "Expected QUOTA_EXCEEDED error, because the budget was exceeded" + + await wallet3.close() + await wallet1.close() + + +@pytest.mark.asyncio +async def test_budget_refresh(): + nwc1 = await create_nwc("wallet1", "test_expiry", ["invoice", "pay", "balance"], [], 0) + nwc3 = await create_nwc("wallet3", "test_expiry", ["invoice", "pay", "balance"], [ { + "budget_msats": 100000, + "refresh_window": 5, + "created_at": time.time() + }], 0) + wallet1 = NWCWallet(nwc1["pairing"]) + wallet3 = NWCWallet(nwc3["pairing"]) + await wallet3.start() + await wallet1.start() + await wallet1.sendEvent("make_invoice", { + "amount": 100000, + "description": "Invalid" + }) + result, tags, error = await wallet1.waitFor("make_invoice") + assert not error + + await wallet1.sendEvent("make_invoice", { + "amount": 100000, + "description": "Invalid" + }) + result2, tags, error = await wallet1.waitFor("make_invoice") + assert not error + + await wallet3.sendEvent("pay_invoice", { + "invoice": result["invoice"] + }) + result, tags, error = await wallet3.waitFor("pay_invoice") + assert not error, "Expected successful payment, because the budget was not exceeded" + + + await wallet3.sendEvent("pay_invoice", { + "invoice": result2["invoice"] + }) + result, tags, error = await wallet3.waitFor("pay_invoice") + assert error + assert error["code"] == "QUOTA_EXCEEDED", "Expected QUOTA_EXCEEDED error, because the budget was exceeded" + + await asyncio.sleep(5) + await wallet1.sendEvent("make_invoice", { + "amount": 100000, + "description": "Valid" + }) + result, tags, error = await wallet1.waitFor("make_invoice") + assert not error + + + await wallet3.sendEvent("pay_invoice", { + "invoice": result["invoice"] + }) + result, tags, error = await wallet3.waitFor("pay_invoice") + assert not error, "Expected successful payment, because the budget was refreshed" + + await wallet3.close() + await wallet1.close() + + + + +