This commit is contained in:
Riccardo Balbo 2024-07-25 20:13:34 +00:00
commit 7fbaf437d6
11 changed files with 1335 additions and 5 deletions

252
tests/integration/.env Normal file
View file

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

View file

@ -0,0 +1 @@
yes v039fk_lnbits_integration_test_folder

BIN
tests/integration/data.zip Normal file

Binary file not shown.

View file

@ -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

View file

@ -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

View file

@ -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
}
}

View file

@ -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()