Compare commits

...

9 commits

Author SHA1 Message Date
204b559f78 misc docs/helpers 2025-10-14 09:49:08 +02:00
0ba1719046 FIX: correct nixos module state directories
Particularly, this places the allows the extensions folder to be
/var/lib/lnbits/extensions and the data folder to be
/var/lib/lnbits/data
2025-10-14 09:48:44 +02:00
db025145aa feat: publish Nostr metadata events for new user accounts
- Added functionality to publish Nostr kind 0 metadata events during
user account creation if the user has a username and Nostr keys.
- Implemented error handling and logging for the metadata publishing
process.
- Introduced helper functions to manage the creation and publishing of
Nostr events to multiple relays.

refactor: improve relay handling in Nostr metadata publishing

- Updated the relay extraction logic to ensure only valid relay URLs are used.
- Added logging for retrieved relays and the number of active relays found.
- Removed default relay fallback, opting to skip publishing if no relays are configured, with appropriate logging for this scenario.

fix: increase WebSocket connection timeout for relay publishing

- Updated the timeout for WebSocket connections in the event publishing function from 3 seconds to 9 seconds to improve reliability in relay communication.

refactor: streamline Nostr metadata publishing by inserting directly into nostrrelay database

- Removed the WebSocket relay publishing method in favor of a direct database insertion approach to simplify the process and avoid WebSocket issues.
- Updated the logic to retrieve relay information from nostrclient and handle potential import errors more gracefully.
- Enhanced logging for the new insertion method and added fallback mechanisms for relay identification.
2025-10-14 09:48:44 +02:00
a45d7ad11d Merge branch 'auto-lnurlp-acct' into dev-pm 2025-10-14 09:48:42 +02:00
b5806533e6 Merge branch 'auto-lnurlp-acct' into dev-pm 2025-10-14 09:48:04 +02:00
3bae338b30 Merge branch 'auto-lnurlp-acct' into dev-pm 2025-10-14 09:47:41 +02:00
e8d629c2ba feat: add default pay link creation for users with username in user account setup 2025-10-14 00:13:27 +02:00
adddcecf87 feat: integrate Nostr keypair generation with LNBits user accounts
- Added Nostr private key storage to the accounts table via a new
migration.
- Updated Account model to include Nostr private key field.
- Modified user creation process to automatically generate Nostr
keypairs.
- Introduced new API endpoints for retrieving Nostr public keys and user
information including private keys.
- Implemented tests to verify Nostr keypair generation and account model
updates.
2025-10-14 00:13:27 +02:00
c7609fedf5 feat: add default pay link creation for users with username in user account setup 2025-10-14 00:13:27 +02:00
15 changed files with 1429 additions and 23 deletions

View file

@ -298,6 +298,7 @@ async def create_user(username: str, password: str):
account.hash_password(password)
user = await create_user_account_no_ckeck(account)
click.echo(f"User '{user.username}' created. Id: '{user.id}'")
click.echo(f"Nostr public key: {account.pubkey}")
@users.command("cleanup-accounts")

View file

@ -23,9 +23,32 @@ async def create_account(
) -> Account:
if account:
account.validate_fields()
# If account doesn't have Nostr keys, generate them
# Exception: Nostr login users who already have a public key but no private key
# should not get a new private key generated - they use their existing Nostr identity
if not account.pubkey and not account.prvkey:
from lnbits.utils.nostr import generate_keypair
nostr_private_key, nostr_public_key = generate_keypair()
account.pubkey = nostr_public_key
account.prvkey = nostr_private_key
elif account.pubkey and not account.prvkey:
# This is a Nostr login user - they already have a public key from their existing identity
# We don't generate a private key for them as they use their own Nostr client
# The chat system will need to handle this case by requesting the private key from the user
pass
else:
# Generate Nostr keypair for new account
from lnbits.utils.nostr import generate_keypair
nostr_private_key, nostr_public_key = generate_keypair()
now = datetime.now(timezone.utc)
account = Account(id=uuid4().hex, created_at=now, updated_at=now)
account = Account(
id=uuid4().hex,
created_at=now,
updated_at=now,
pubkey=nostr_public_key, # Use Nostr public key as the pubkey
prvkey=nostr_private_key,
)
await (conn or db).insert("accounts", account)
return account
@ -68,6 +91,7 @@ async def get_accounts(
accounts.username,
accounts.email,
accounts.pubkey,
accounts.prvkey,
accounts.external_id,
SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
@ -182,11 +206,12 @@ async def get_user_from_account(
) -> Optional[User]:
extensions = await get_user_active_extensions_ids(account.id, conn)
wallets = await get_wallets(account.id, False, conn=conn)
return User(
id=account.id,
email=account.email,
username=account.username,
pubkey=account.pubkey,
pubkey=account.pubkey, # This is now the Nostr public key
external_id=account.external_id,
extra=account.extra,
created_at=account.created_at,

View file

@ -238,12 +238,12 @@ async def m007_set_invoice_expiries(db: Connection):
expiration_date = invoice.date + invoice.expiry
logger.info(
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
f"Migration: {i + 1}/{len(rows)} setting expiry of invoice"
f" {invoice.payment_hash} to {expiration_date}"
)
await db.execute(
f"""
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
UPDATE apipayments SET expiry = {db.timestamp_placeholder("expiry")}
WHERE checking_id = :checking_id AND amount > 0
""",
{"expiry": expiration_date, "checking_id": checking_id},
@ -455,14 +455,14 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
now = int(time())
await db.execute(
f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
UPDATE wallets SET created_at = {db.timestamp_placeholder("now")}
WHERE created_at IS NULL
""",
{"now": now},
)
await db.execute(
f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
UPDATE accounts SET created_at = {db.timestamp_placeholder("now")}
WHERE created_at IS NULL
""",
{"now": now},
@ -613,7 +613,7 @@ async def m027_update_apipayments_data(db: Connection):
payments: list[dict[Any, Any]] = []
logger.info("Updating payments")
while len(payments) > 0 or offset == 0:
logger.info(f"Updating {offset} to {offset+limit}")
logger.info(f"Updating {offset} to {offset + limit}")
result = await db.execute(
f"SELECT * FROM apipayments ORDER BY time LIMIT {limit} OFFSET {offset}"
@ -645,7 +645,6 @@ async def m027_update_apipayments_data(db: Connection):
async def m028_update_settings(db: Connection):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS system_settings (
@ -723,3 +722,13 @@ async def m032_add_external_id_to_accounts(db: Connection):
async def m033_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
async def m9001_add_nostr_private_key_to_accounts(db: Connection):
"""
Adds prvkey column to accounts for storing Nostr private keys.
FORK MIGRATION - DO NOT MERGE UPSTREAM
"""
try:
await db.execute("ALTER TABLE accounts ADD COLUMN prvkey TEXT")
except OperationalError:
pass

View file

@ -102,6 +102,7 @@ class Account(BaseModel):
username: str | None = None
password_hash: str | None = None
pubkey: str | None = None
prvkey: str | None = None # Nostr private key for user
email: str | None = None
extra: UserExtra = UserExtra()
@ -114,6 +115,19 @@ class Account(BaseModel):
def __init__(self, **data):
super().__init__(**data)
# NOTE: I tried this in the past and it resulted in unexpected behavior
# all accounts were suddenly showing up in the peers list, however, if
# they did not have a key-pair, they were being assigned one on the fly.
# Something about fetching the users was causing this code to trigger.
#
#
# # Generate Nostr keypair if not already provided
# if not self.pubkey or not self.prvkey:
# from lnbits.utils.nostr import generate_keypair
# nostr_public_key, nostr_private_key = generate_keypair()
# self.pubkey = nostr_public_key
# self.prvkey = nostr_private_key
#
self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
@ -187,7 +201,7 @@ class User(BaseModel):
updated_at: datetime
email: str | None = None
username: str | None = None
pubkey: str | None = None
pubkey: str | None = None # This is now the Nostr public key
external_id: str | None = None # for external account linking
extensions: list[str] = []
wallets: list[Wallet] = []

View file

@ -1,3 +1,5 @@
import json
import time
from pathlib import Path
from typing import Optional
from uuid import uuid4
@ -85,6 +87,12 @@ async def create_user_account_no_ckeck(
logger.info(f"Created default pay link for user {account.username}")
except Exception as e:
logger.error(f"Failed to create default pay link for user {account.username}: {e}")
# Publish Nostr kind 0 metadata event if user has username and Nostr keys
try:
await _publish_nostr_metadata_event(account)
logger.info(f"Published Nostr metadata event for user {account.username}")
except Exception as e:
logger.error(f"Failed to publish Nostr metadata for user {account.username}: {e}")
user = await get_user_from_account(account)
assert user, "Cannot find user for account."
@ -199,7 +207,7 @@ async def _create_default_pay_link(account: Account, wallet) -> None:
# Try dynamic import that works with extensions in different locations
import importlib
import sys
# First try the standard import path
try:
lnurlp_crud = importlib.import_module("lnbits.extensions.lnurlp.crud")
@ -212,16 +220,16 @@ async def _create_default_pay_link(account: Account, wallet) -> None:
extensions_path = settings.lnbits_extensions_path or "/var/lib/lnbits/extensions"
if extensions_path not in sys.path:
sys.path.insert(0, extensions_path)
lnurlp_crud = importlib.import_module("lnurlp.crud")
lnurlp_models = importlib.import_module("lnurlp.models")
except ImportError as e:
logger.warning(f"lnurlp extension not found in any location: {e}")
return
create_pay_link = lnurlp_crud.create_pay_link
CreatePayLinkData = lnurlp_models.CreatePayLinkData
pay_link_data = CreatePayLinkData(
description="Bitcoinmat Receiving Address",
wallet=wallet.id,
@ -236,7 +244,108 @@ async def _create_default_pay_link(account: Account, wallet) -> None:
await create_pay_link(pay_link_data)
logger.info(f"Successfully created default pay link for user {account.username}")
except Exception as e:
logger.error(f"Failed to create default pay link: {e}")
# Don't raise - we don't want user creation to fail if pay link creation fails
async def _publish_nostr_metadata_event(account: Account) -> None:
"""Publish a Nostr kind 0 metadata event for a new user"""
try:
import importlib
import sys
import secp256k1
# Note: We publish directly to nostrrelay database, no need to get relay URLs
# Create Nostr kind 0 metadata event
metadata = {
"name": account.username,
"display_name": account.username,
"about": f"LNbits user: {account.username}"
}
event = {
"kind": 0,
"created_at": int(time.time()),
"tags": [],
"content": json.dumps(metadata)
}
# Sign the event using LNbits utilities
from lnbits.utils.nostr import sign_event
# Convert hex private key to secp256k1 PrivateKey
private_key = secp256k1.PrivateKey(bytes.fromhex(account.prvkey))
# Sign the event
signed_event = sign_event(event, account.pubkey, private_key)
# Publish directly to nostrrelay database (hacky but works around WebSocket issues)
await _insert_event_into_nostrrelay(signed_event, account.username)
except Exception as e:
logger.error(f"Failed to publish Nostr metadata event: {e}")
# Don't raise - we don't want user creation to fail if Nostr publishing fails
async def _insert_event_into_nostrrelay(event: dict, username: str) -> None:
"""Directly insert Nostr event into nostrrelay database (hacky workaround)"""
try:
import importlib
import sys
# Try to import nostrrelay from various possible locations
nostrrelay_crud = None
NostrEvent = None
try:
nostrrelay_crud = importlib.import_module("lnbits.extensions.nostrrelay.crud")
NostrEvent = importlib.import_module("lnbits.extensions.nostrrelay.relay.event").NostrEvent
except ImportError:
try:
# Check if nostrrelay is in external extensions path
extensions_path = settings.lnbits_extensions_path or "/var/lib/lnbits/extensions"
if extensions_path not in sys.path:
sys.path.insert(0, extensions_path)
nostrrelay_crud = importlib.import_module("nostrrelay.crud")
NostrEvent = importlib.import_module("nostrrelay.relay.event").NostrEvent
except ImportError:
# Try from the lnbits-nostrmarket project path
nostrmarket_path = "/home/padreug/Projects/lnbits-nostrmarket"
if nostrmarket_path not in sys.path:
sys.path.insert(0, nostrmarket_path)
nostrrelay_crud = importlib.import_module("nostrrelay.crud")
NostrEvent = importlib.import_module("nostrrelay.relay.event").NostrEvent
if not nostrrelay_crud or not NostrEvent:
logger.warning("Could not import nostrrelay - skipping direct database insert")
return
# Use a default relay_id for the proof of concept
relay_id = "test1"
logger.debug(f"Using relay_id: {relay_id} for nostrrelay event")
# Create NostrEvent object for nostrrelay
nostr_event = NostrEvent(
id=event["id"],
relay_id=relay_id,
publisher=event["pubkey"],
pubkey=event["pubkey"],
created_at=event["created_at"],
kind=event["kind"],
tags=event.get("tags", []),
content=event["content"],
sig=event["sig"]
)
# Insert directly into nostrrelay database
await nostrrelay_crud.create_event(nostr_event)
logger.info(f"Successfully inserted Nostr metadata event for {username} into nostrrelay database")
except Exception as e:
logger.error(f"Failed to insert event into nostrrelay database: {e}")
logger.debug(f"Exception details: {type(e).__name__}: {str(e)}")

View file

@ -70,6 +70,49 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
return user
@auth_router.get("/nostr/me", description="Get current user with Nostr keys")
async def get_auth_user_with_nostr(user: User = Depends(check_user_exists)) -> dict:
"""Get current user information including Nostr private key for chat"""
from lnbits.core.crud.users import get_account
# Get the account to access the private key
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return {
"id": user.id,
"username": user.username,
"email": user.email,
"pubkey": user.pubkey,
"prvkey": account.prvkey, # Include private key for Nostr chat
"created_at": user.created_at,
"updated_at": user.updated_at
}
@auth_router.get("/nostr/pubkeys", description="Get all user Nostr public keys")
async def get_nostr_pubkeys(user: User = Depends(check_user_exists)) -> list[dict[str, str]]:
"""Get all user Nostr public keys for chat"""
from lnbits.core.crud.users import get_accounts
from lnbits.db import Filters
# Get all accounts
filters = Filters()
accounts_page = await get_accounts(filters=filters)
pubkeys = []
for account in accounts_page.data:
if account.pubkey: # pubkey is now the Nostr public key
pubkeys.append({
"user_id": account.id,
"username": account.username,
"pubkey": account.pubkey
})
return pubkeys
@auth_router.post("", description="Login via the username and password")
async def login(data: LoginUsernamePassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):

View file

@ -14,6 +14,7 @@ from lnbits.core.crud import (
delete_account,
delete_wallet,
force_delete_wallet,
get_account,
get_accounts,
get_user,
get_wallet,
@ -41,7 +42,7 @@ from lnbits.core.services import (
update_wallet_balance,
)
from lnbits.db import Filters, Page
from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.decorators import check_admin, check_super_user, check_user_exists, parse_filters
from lnbits.helpers import (
encrypt_internal_message,
generate_filter_params_openapi,
@ -99,7 +100,6 @@ async def api_create_user(data: CreateUser) -> CreateUser:
id=uuid4().hex,
username=data.username,
email=data.email,
pubkey=data.pubkey,
external_id=data.external_id,
extra=data.extra,
)
@ -331,3 +331,54 @@ async def api_update_balance(data: UpdateBalance) -> SimpleStatus:
)
return SimpleStatus(success=True, message="Balance updated.")
@users_router.get(
"/nostr/pubkeys",
name="Get all user Nostr public keys",
summary="Get a list of all user Nostr public keys",
dependencies=[], # Override global admin requirement
)
async def api_get_nostr_pubkeys() -> list[dict[str, str]]:
"""Get all user Nostr public keys"""
from lnbits.core.crud.users import get_accounts
from lnbits.db import Filters
# Get all accounts
filters = Filters()
accounts_page = await get_accounts(filters=filters)
pubkeys = []
for account in accounts_page.data:
if account.pubkey: # pubkey is now the Nostr public key
pubkeys.append({
"user_id": account.id,
"username": account.username,
"pubkey": account.pubkey # Use consistent naming
})
return pubkeys
@users_router.get(
"/user/me",
name="Get current user",
summary="Get current user information including private key",
dependencies=[], # Override global admin requirement
)
async def api_get_current_user(user: User = Depends(check_user_exists)) -> dict:
"""Get current user information including private key for Nostr chat"""
# Get the account to access the private key
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return {
"id": user.id,
"username": user.username,
"email": user.email,
"pubkey": user.pubkey,
"prvkey": account.prvkey, # Include private key for Nostr chat
"created_at": user.created_at,
"updated_at": user.updated_at
}

View file

@ -0,0 +1,140 @@
# Nostr Integration for LNBits Users
This document describes the changes made to integrate Nostr keypairs with LNBits user accounts.
## Overview
The integration adds Nostr keypair generation to user accounts, allowing each user to have a unique Nostr identity. The private key is stored securely in the database, while the public key is derived and made available through the API.
## Changes Made
### 1. Database Migration
**File**: `lnbits/core/migrations.py`
- Added `m034_add_nostr_private_key_to_accounts()` migration
- Adds `prvkey` column to the `accounts` table for Nostr private keys
### 2. Model Updates
**File**: `lnbits/core/models/users.py`
- Added `prvkey` field to `Account` model for Nostr private key
- The existing `pubkey` field is now used to store the Nostr public key
### 3. CRUD Operations
**File**: `lnbits/core/crud/users.py`
- Updated `get_user_from_account()` to use the existing pubkey field (now contains Nostr public key)
- Updated `get_accounts()` SQL query to include `prvkey` field
### 4. API Endpoints
**File**: `lnbits/core/views/user_api.py`
- Modified user creation to automatically generate Nostr keypair and set the pubkey field
- Added new endpoint `/users/api/v1/nostr/pubkeys` to get all user public keys
- Endpoint requires admin privileges and returns:
```json
[
{
"user_id": "user_id",
"username": "username",
"nostr_public_key": "public_key_hex"
}
]
```
### 5. Command Line Interface
**File**: `lnbits/commands.py`
- Updated `create_user` command to generate Nostr keypair
- Displays the generated public key when creating users via CLI
## API Usage
### Creating a User (Automatically generates Nostr keypair)
```bash
POST /users/api/v1/user
Content-Type: application/json
{
"username": "testuser",
"email": "test@example.com",
"password": "password123",
"password_repeat": "password123"
}
```
### Getting All User Public Keys
```bash
GET /users/api/v1/nostr/pubkeys
Authorization: Bearer <admin_token>
Response:
[
{
"user_id": "abc123",
"username": "user1",
"pubkey": "02a1b2c3d4e5f6..."
},
{
"user_id": "def456",
"username": "user2",
"pubkey": "03b2c3d4e5f6a1..."
}
]
```
### Getting Individual User (includes Nostr public key)
```bash
GET /users/api/v1/user/{user_id}
Response:
{
"id": "abc123",
"username": "user1",
"email": "user1@example.com",
"pubkey": "02a1b2c3d4e5f6...", # This is the Nostr public key
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
...
}
```
## Security Considerations
1. **Private Key Storage**: Nostr private keys are stored in the `prvkey` field but are never exposed through the API
2. **Public Key Storage**: Nostr public keys are stored directly in the `pubkey` field for efficiency
3. **Admin Access**: The public key listing endpoint requires admin privileges
4. **Consistent Naming**: `pubkey` and `prvkey` provide clear, consistent field names
## Migration
To apply the database changes:
1. Run the migration: `python -m lnbits db migrate`
2. The migration will add the `prvkey` column to existing accounts for Nostr private keys
3. New users will automatically get Nostr keypairs generated
## Testing
Use the provided test script to verify the integration:
```bash
python test_nostr_integration.py
```
## Dependencies
The integration uses the existing `lnbits.utils.nostr` module which provides:
- `generate_keypair()`: Generates new Nostr keypairs
- `PrivateKey` class: For key manipulation and public key derivation
## Future Enhancements
Potential improvements could include:
1. NIP-19 encoding support (npub/nsec format)
2. Nostr event signing capabilities
3. Integration with Nostr relays for user discovery
4. User profile metadata storage

View file

@ -0,0 +1,460 @@
# LNbits WebSocket Implementation Guide
## Overview
LNbits provides real-time WebSocket connections for monitoring wallet status, payment confirmations, and transaction updates. This guide covers how to implement and use these WebSocket connections in your applications.
## WebSocket Endpoints
### 1. Payment Monitoring WebSocket
- **URL**: `ws://localhost:5006/api/v1/ws/{wallet_inkey}`
- **HTTPS**: `wss://your-domain.com/api/v1/ws/{wallet_inkey}`
- **Purpose**: Real-time payment notifications and wallet updates
### 2. Generic WebSocket Communication
- **URL**: `ws://localhost:5006/api/v1/ws/{item_id}`
- **Purpose**: Custom real-time communication channels
## Client-Side Implementation
### JavaScript/Browser Implementation
#### Basic WebSocket Connection
```javascript
// Construct WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const websocketUrl = `${protocol}//${window.location.host}/api/v1/ws`
// Connect to payment monitoring
const ws = new WebSocket(`${websocketUrl}/${wallet.inkey}`)
// Handle incoming messages
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
console.log('Received:', data)
if (data.payment) {
handlePaymentReceived(data.payment)
}
}
// Handle connection events
ws.onopen = () => {
console.log('WebSocket connected')
}
ws.onclose = () => {
console.log('WebSocket disconnected')
}
ws.onerror = (error) => {
console.error('WebSocket error:', error)
}
```
#### Using LNbits Built-in Event System
```javascript
// Using the built-in LNbits event system
LNbits.events.onInvoicePaid(wallet, (data) => {
if (data.payment) {
console.log('Payment confirmed:', data.payment)
// Update UI
updateWalletBalance(data.payment.amount)
showPaymentNotification(data.payment)
}
})
```
#### Vue.js Implementation Example
```javascript
// Vue component method
initWebSocket() {
const protocol = location.protocol === 'http:' ? 'ws://' : 'wss://'
const wsUrl = `${protocol}${document.domain}:${location.port}/api/v1/ws/${this.wallet.inkey}`
this.ws = new WebSocket(wsUrl)
this.ws.addEventListener('message', async ({ data }) => {
const response = JSON.parse(data.toString())
if (response.payment) {
// Handle payment update
await this.handlePaymentUpdate(response.payment)
}
})
this.ws.addEventListener('open', () => {
this.connectionStatus = 'connected'
})
this.ws.addEventListener('close', () => {
this.connectionStatus = 'disconnected'
// Implement reconnection logic
setTimeout(() => this.initWebSocket(), 5000)
})
}
```
### Python Client Implementation
```python
import asyncio
import websockets
import json
async def listen_to_wallet(wallet_inkey, base_url="ws://localhost:5006"):
uri = f"{base_url}/api/v1/ws/{wallet_inkey}"
try:
async with websockets.connect(uri) as websocket:
print(f"Connected to WebSocket: {uri}")
async for message in websocket:
data = json.loads(message)
if 'payment' in data:
payment = data['payment']
print(f"Payment received: {payment['amount']} sat")
print(f"Payment hash: {payment['payment_hash']}")
# Process payment
await handle_payment_received(payment)
except websockets.exceptions.ConnectionClosed:
print("WebSocket connection closed")
except Exception as e:
print(f"WebSocket error: {e}")
async def handle_payment_received(payment):
"""Process incoming payment"""
# Update database
# Send notifications
# Update application state
pass
# Run the WebSocket listener
if __name__ == "__main__":
wallet_inkey = "your_wallet_inkey_here"
asyncio.run(listen_to_wallet(wallet_inkey))
```
### Node.js Client Implementation
```javascript
const WebSocket = require('ws')
class LNbitsWebSocketClient {
constructor(walletInkey, baseUrl = 'ws://localhost:5006') {
this.walletInkey = walletInkey
this.baseUrl = baseUrl
this.ws = null
this.reconnectInterval = 5000
}
connect() {
const url = `${this.baseUrl}/api/v1/ws/${this.walletInkey}`
this.ws = new WebSocket(url)
this.ws.on('open', () => {
console.log(`Connected to LNbits WebSocket: ${url}`)
})
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString())
this.handleMessage(message)
} catch (error) {
console.error('Error parsing WebSocket message:', error)
}
})
this.ws.on('close', () => {
console.log('WebSocket connection closed. Reconnecting...')
setTimeout(() => this.connect(), this.reconnectInterval)
})
this.ws.on('error', (error) => {
console.error('WebSocket error:', error)
})
}
handleMessage(message) {
if (message.payment) {
console.log('Payment received:', message.payment)
this.onPaymentReceived(message.payment)
}
}
onPaymentReceived(payment) {
// Override this method to handle payments
console.log(`Received ${payment.amount} sat`)
}
disconnect() {
if (this.ws) {
this.ws.close()
}
}
}
// Usage
const client = new LNbitsWebSocketClient('your_wallet_inkey_here')
client.onPaymentReceived = (payment) => {
// Custom payment handling
console.log(`Processing payment: ${payment.payment_hash}`)
}
client.connect()
```
## Server-Side Implementation (LNbits Extensions)
### Sending WebSocket Updates
```python
from lnbits.core.services import websocket_manager
async def notify_wallet_update(wallet_inkey: str, payment_data: dict):
"""Send payment update to connected WebSocket clients"""
message = {
"payment": payment_data,
"timestamp": int(time.time())
}
await websocket_manager.send(wallet_inkey, json.dumps(message))
# Example usage in payment processing
async def process_payment_confirmation(payment_hash: str):
payment = await get_payment(payment_hash)
if payment.wallet:
await notify_wallet_update(payment.wallet, {
"payment_hash": payment.payment_hash,
"amount": payment.amount,
"memo": payment.memo,
"status": "confirmed"
})
```
### HTTP Endpoints for WebSocket Updates
```python
# Send data via GET request
@router.get("/notify/{wallet_inkey}/{message}")
async def notify_wallet_get(wallet_inkey: str, message: str):
await websocket_manager.send(wallet_inkey, message)
return {"sent": True, "message": message}
# Send data via POST request
@router.post("/notify/{wallet_inkey}")
async def notify_wallet_post(wallet_inkey: str, data: str):
await websocket_manager.send(wallet_inkey, data)
return {"sent": True, "data": data}
```
## Message Format
### Payment Notification Message
```json
{
"payment": {
"payment_hash": "abc123...",
"amount": 1000,
"memo": "Test payment",
"status": "confirmed",
"timestamp": 1640995200,
"fee": 1,
"wallet_id": "wallet_uuid"
}
}
```
### Custom Message Format
```json
{
"type": "balance_update",
"wallet_id": "wallet_uuid",
"balance": 50000,
"timestamp": 1640995200
}
```
## Best Practices
### 1. Connection Management
- Implement automatic reconnection logic
- Handle connection timeouts gracefully
- Use exponential backoff for reconnection attempts
### 2. Error Handling
```javascript
class WebSocketManager {
constructor(walletInkey) {
this.walletInkey = walletInkey
this.maxReconnectAttempts = 10
this.reconnectAttempts = 0
this.reconnectDelay = 1000
}
connect() {
try {
this.ws = new WebSocket(this.getWebSocketUrl())
this.setupEventHandlers()
} catch (error) {
this.handleConnectionError(error)
}
}
handleConnectionError(error) {
console.error('WebSocket connection error:', error)
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
setTimeout(() => {
console.log(`Reconnection attempt ${this.reconnectAttempts}`)
this.connect()
}, delay)
} else {
console.error('Max reconnection attempts reached')
}
}
}
```
### 3. Message Validation
```javascript
function validatePaymentMessage(data) {
if (!data.payment) return false
const payment = data.payment
return (
typeof payment.payment_hash === 'string' &&
typeof payment.amount === 'number' &&
payment.amount > 0 &&
['pending', 'confirmed', 'failed'].includes(payment.status)
)
}
```
### 4. Security Considerations
- Use HTTPS/WSS in production
- Validate wallet permissions before connecting
- Implement rate limiting for WebSocket connections
- Never expose admin keys through WebSocket messages
## Testing WebSocket Connections
### Using wscat (Command Line Tool)
```bash
# Install wscat
npm install -g wscat
# Connect to WebSocket
wscat -c ws://localhost:5006/api/v1/ws/your_wallet_inkey
# Test with SSL
wscat -c wss://your-domain.com/api/v1/ws/your_wallet_inkey
```
### Browser Console Testing
```javascript
// Open browser console and run:
const ws = new WebSocket('ws://localhost:5006/api/v1/ws/your_wallet_inkey')
ws.onmessage = (e) => console.log('Received:', JSON.parse(e.data))
ws.onopen = () => console.log('Connected')
ws.onclose = () => console.log('Disconnected')
```
### Sending Test Messages
```bash
# Using curl to trigger WebSocket message
curl "http://localhost:5006/api/v1/ws/your_wallet_inkey/test_message"
# Using POST
curl -X POST "http://localhost:5006/api/v1/ws/your_wallet_inkey" \
-H "Content-Type: application/json" \
-d '"test message data"'
```
## Troubleshooting
### Common Issues
1. **Connection Refused**
- Verify LNbits server is running on correct port
- Check firewall settings
- Ensure WebSocket endpoint is enabled
2. **Authentication Errors**
- Verify wallet inkey is correct
- Check wallet permissions
- Ensure wallet exists and is active
3. **Message Not Received**
- Check WebSocket connection status
- Verify message format
- Test with browser dev tools
4. **Frequent Disconnections**
- Implement proper reconnection logic
- Check network stability
- Monitor server logs for errors
### Debug Logging
```javascript
// Enable verbose WebSocket logging
const ws = new WebSocket(wsUrl)
ws.addEventListener('open', (event) => {
console.log('WebSocket opened:', event)
})
ws.addEventListener('close', (event) => {
console.log('WebSocket closed:', event.code, event.reason)
})
ws.addEventListener('error', (event) => {
console.error('WebSocket error:', event)
})
```
## Production Deployment
### Nginx Configuration
```nginx
location /api/v1/ws/ {
proxy_pass http://localhost:5006;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
```
### SSL/TLS Configuration
```nginx
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location /api/v1/ws/ {
proxy_pass http://localhost:5006;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# ... other headers
}
}
```
## Conclusion
LNbits WebSocket implementation provides a robust foundation for real-time wallet monitoring and payment processing. By following this guide, you can implement reliable WebSocket connections that enhance user experience with instant payment notifications and live wallet updates.
Remember to implement proper error handling, reconnection logic, and security measures when deploying to production environments.

Binary file not shown.

View file

@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Bulk publish Nostr profiles from CSV file
Usage: python publish_profiles_from_csv.py <csv_file> <relay_url>
"""
import sys
import csv
import json
import time
import hashlib
import secp256k1
from websocket import create_connection
def sign_event(event, public_key_hex, private_key):
"""Sign a Nostr event"""
# Create the signature data
signature_data = json.dumps([
0,
public_key_hex,
event["created_at"],
event["kind"],
event["tags"],
event["content"]
], separators=(',', ':'), ensure_ascii=False)
# Calculate event ID
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id
event["pubkey"] = public_key_hex
# Sign the event
signature = private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True).hex()
event["sig"] = signature
return event
def publish_profile_metadata(private_key_hex, profile_name, relay_url):
"""Publish a Nostr kind 0 metadata event"""
try:
# Convert hex private key to secp256k1 PrivateKey and get public key
private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex))
public_key_hex = private_key.pubkey.serialize()[1:].hex() # Remove the 0x02 prefix
print(f"Publishing profile for: {profile_name}")
print(f" Public key: {public_key_hex}")
# Create Nostr kind 0 metadata event
metadata = {
"name": profile_name,
"display_name": profile_name,
"about": f"Profile for {profile_name}"
}
event = {
"kind": 0,
"created_at": int(time.time()),
"tags": [],
"content": json.dumps(metadata, separators=(',', ':'))
}
# Sign the event
signed_event = sign_event(event, public_key_hex, private_key)
# Connect to relay and publish
ws = create_connection(relay_url, timeout=15)
# Send the event
event_message = f'["EVENT",{json.dumps(signed_event)}]'
ws.send(event_message)
# Wait for response
try:
response = ws.recv()
print(f" ✅ Published successfully: {response}")
except Exception as e:
print(f" ⚠️ No immediate response: {e}")
# Close connection
ws.close()
return True
except Exception as e:
print(f" ❌ Failed to publish: {e}")
return False
def main():
if len(sys.argv) != 3:
print("Usage: python publish_profiles_from_csv.py <csv_file> <relay_url>")
print("Example: python publish_profiles_from_csv.py publish-these.csv wss://relay.example.com")
sys.exit(1)
csv_file = sys.argv[1]
relay_url = sys.argv[2]
print(f"Publishing profiles from {csv_file} to {relay_url}")
print("=" * 60)
published_count = 0
failed_count = 0
try:
with open(csv_file, 'r') as file:
csv_reader = csv.DictReader(file)
for row_num, row in enumerate(csv_reader, start=2): # start=2 because header is line 1
username = row['username'].strip()
private_key_hex = row['prvkey'].strip()
if not username or not private_key_hex:
print(f"Row {row_num}: Skipping empty row")
continue
print(f"\nRow {row_num}: Processing {username}")
success = publish_profile_metadata(private_key_hex, username, relay_url)
if success:
published_count += 1
else:
failed_count += 1
# Small delay between publishes to be nice to the relay
time.sleep(1)
except FileNotFoundError:
print(f"❌ File not found: {csv_file}")
sys.exit(1)
except Exception as e:
print(f"❌ Error processing CSV: {e}")
sys.exit(1)
print("\n" + "=" * 60)
print(f"Publishing complete!")
print(f"✅ Successfully published: {published_count}")
print(f"❌ Failed: {failed_count}")
print(f"📊 Total processed: {published_count + failed_count}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
Manual Nostr profile metadata publisher
Usage: python test_nostr_connection.py <private_key_hex> <profile_name> <relay_url>
"""
import sys
import json
import time
import hashlib
import secp256k1
from websocket import create_connection
def sign_event(event, public_key_hex, private_key):
"""Sign a Nostr event"""
# Create the signature data
signature_data = json.dumps([
0,
public_key_hex,
event["created_at"],
event["kind"],
event["tags"],
event["content"]
], separators=(',', ':'), ensure_ascii=False)
# Calculate event ID
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id
event["pubkey"] = public_key_hex
# Sign the event
signature = private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True).hex()
event["sig"] = signature
return event
def publish_profile_metadata(private_key_hex, profile_name, relay_url):
"""Publish a Nostr kind 0 metadata event"""
try:
# Convert hex private key to secp256k1 PrivateKey and get public key
private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex))
public_key_hex = private_key.pubkey.serialize()[1:].hex() # Remove the 0x02 prefix
print(f"Private key: {private_key_hex}")
print(f"Public key: {public_key_hex}")
print(f"Profile name: {profile_name}")
print(f"Relay URL: {relay_url}")
print()
# Create Nostr kind 0 metadata event
metadata = {
"name": profile_name,
"display_name": profile_name,
"about": f"Manual profile update for {profile_name}"
}
event = {
"kind": 0,
"created_at": int(time.time()),
"tags": [],
"content": json.dumps(metadata, separators=(',', ':'))
}
# Sign the event
signed_event = sign_event(event, public_key_hex, private_key)
print(f"Signed event: {json.dumps(signed_event, indent=2)}")
print()
# Connect to relay and publish
print(f"Connecting to relay: {relay_url}")
ws = create_connection(relay_url, timeout=15)
print("✅ Connected successfully!")
# Send the event
event_message = f'["EVENT",{json.dumps(signed_event)}]'
print(f"Sending EVENT: {event_message}")
ws.send(event_message)
# Wait for response
try:
response = ws.recv()
print(f"✅ Relay response: {response}")
except Exception as e:
print(f"⚠️ No immediate response: {e}")
# Close connection
ws.close()
print("✅ Connection closed successfully")
print(f"Profile metadata for '{profile_name}' published successfully!")
except Exception as e:
print(f"❌ Failed to publish profile: {e}")
raise
if __name__ == "__main__":
if len(sys.argv) != 4:
print("Usage: python test_nostr_connection.py <private_key_hex> <profile_name> <relay_url>")
print("Example: python test_nostr_connection.py abc123... 'My Name' wss://relay.example.com")
sys.exit(1)
private_key_hex = sys.argv[1]
profile_name = sys.argv[2]
relay_url = sys.argv[3]
publish_profile_metadata(private_key_hex, profile_name, relay_url)

View file

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Test script to verify Nostr keypair integration with LNBits User model
"""
import sys
import os
# Add the lnbits directory to the path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lnbits'))
def test_nostr_keypair_generation():
"""Test that we can generate Nostr keypairs"""
try:
from lnbits.utils.nostr import generate_keypair
private_key, public_key = generate_keypair()
print(f"✓ Nostr keypair generation works")
print(f" Private key: {private_key[:16]}...")
print(f" Public key: {public_key}")
return True
except Exception as e:
print(f"✗ Nostr keypair generation failed: {e}")
return False
def test_account_model():
"""Test that the Account model includes prvkey field"""
try:
from lnbits.core.models.users import Account
account = Account(
id="test123",
username="testuser",
)
print(f"✓ Account model includes prvkey field")
print(f" prvkey: {account.prvkey}")
print(f" pubkey: {account.pubkey}")
return True
except Exception as e:
print(f"✗ Account model test failed: {e}")
return False
def test_user_model():
"""Test that the User model includes pubkey field"""
try:
from lnbits.core.models.users import User
user = User(
id="test123",
created_at=None,
updated_at=None,
pubkey="test_public_key"
)
print(f"✓ User model includes pubkey field")
print(f" pubkey: {user.pubkey}")
return True
except Exception as e:
print(f"✗ User model test failed: {e}")
return False
def test_migration():
"""Test that the migration function exists"""
try:
from lnbits.core.migrations import m034_add_nostr_private_key_to_accounts
print(f"✓ Migration function exists")
return True
except Exception as e:
print(f"✗ Migration test failed: {e}")
return False
def main():
print("Testing Nostr integration with LNBits...")
print("=" * 50)
tests = [
test_nostr_keypair_generation,
test_account_model,
test_user_model,
test_migration,
]
passed = 0
total = len(tests)
for test in tests:
if test():
passed += 1
print()
print("=" * 50)
print(f"Tests passed: {passed}/{total}")
if passed == total:
print("✓ All tests passed! Nostr integration is ready.")
else:
print("✗ Some tests failed. Please check the implementation.")
if __name__ == "__main__":
main()

213
nix/modules/README.md Normal file
View file

@ -0,0 +1,213 @@
# LNBits NixOS Installation Guide
This guide shows how to install LNBits on a fresh NixOS system.
## Quick Start (Recommended)
Add this to your NixOS configuration (`/etc/nixos/configuration.nix`):
```nix
{ config, lib, pkgs, ... }:
let
lnbitsFlake = builtins.getFlake "github:lnbits/lnbits";
in
{
imports = [
# Import LNBits service module directly from GitHub
"${lnbitsFlake}/nix/modules/lnbits-service.nix"
];
# Enable flakes (required)
nix.settings.experimental-features = [ "nix-command" "flakes" ];
# Configure LNBits service
services.lnbits = {
enable = true;
host = "0.0.0.0"; # Listen on all interfaces
port = 5000; # Default port
openFirewall = true; # Open firewall port automatically
# Use package from the same flake (adjust system architecture as needed)
package = lnbitsFlake.packages.x86_64-linux.lnbits;
env = {
LNBITS_ADMIN_UI = "true";
# Configure your Lightning backend:
# LNBITS_BACKEND_WALLET_CLASS = "LndRestWallet";
# LND_REST_ENDPOINT = "https://localhost:8080";
# LND_REST_CERT = "/path/to/tls.cert";
# LND_REST_MACAROON = "/path/to/admin.macaroon";
};
};
# Rebuild and switch
# sudo nixos-rebuild switch
}
```
> **⚠️ System Architecture Note**: The examples above use `x86_64-linux`. Replace this with your system architecture:
>
> - `x86_64-linux` - Intel/AMD 64-bit Linux
> - `aarch64-linux` - ARM 64-bit Linux (e.g., Raspberry Pi 4, Apple Silicon under Linux)
> - `x86_64-darwin` - Intel Mac
> - `aarch64-darwin` - Apple Silicon Mac
>
> You can check your system with: `nix eval --impure --raw --expr 'builtins.currentSystem'`
## Alternative: Using Your Own Flake
Create a `flake.nix` for your system configuration:
```nix
{
description = "My NixOS configuration with LNBits";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
lnbits.url = "github:lnbits/lnbits";
};
outputs = { self, nixpkgs, lnbits }: {
nixosConfigurations.myserver = nixpkgs.lib.nixosSystem {
system = "x86_64-linux"; # Adjust architecture as needed
modules = [
./hardware-configuration.nix
{
services.lnbits = {
enable = true;
host = "0.0.0.0";
port = 5000;
openFirewall = true;
package = lnbits.packages.x86_64-linux.lnbits; # Adjust architecture as needed
env = {
LNBITS_ADMIN_UI = "true";
# Add your Lightning backend configuration
};
};
}
];
};
};
}
```
Then deploy with:
```bash
sudo nixos-rebuild switch --flake .#myserver
```
## Configuration Options
### Basic Options
- `enable`: Enable the LNBits service (default: `false`)
- `host`: Host to bind to (default: `"127.0.0.1"`)
- `port`: Port to run on (default: `8231`)
- `openFirewall`: Automatically open firewall port (default: `false`)
- `user`: User to run as (default: `"lnbits"`)
- `group`: Group to run as (default: `"lnbits"`)
- `stateDir`: State directory (default: `"/var/lib/lnbits"`)
### Environment Variables
Configure LNBits through the `env` option. Common variables:
```nix
services.lnbits.env = {
# Admin UI
LNBITS_ADMIN_UI = "true";
# LND Backend Example:
# LND
LNBITS_BACKEND_WALLET_CLASS = "LndRestWallet";
LND_REST_ENDPOINT = "https://localhost:8080";
LND_REST_CERT = "/path/to/tls.cert";
LND_REST_MACAROON = "/path/to/admin.macaroon";
};
```
See the [LNBits documentation](https://docs.lnbits.org/guide/wallets.html) for all supported backends.
## State Directory Structure
LNBits data is stored in `/var/lib/lnbits` (default) with this structure:
```
/var/lib/lnbits/
├── data/ # Application data
│ ├── database.sqlite3 # Main database
│ ├── ext_<extension>.sqlite3 # Extension database
│ ├── images/ # Uploaded images
│ ├── logs/ # Log files
│ └── upgrades/ # Migration files
└── extensions/ # Installed extensions
```
## First Time Setup
1. **Deploy the configuration:**
```bash
sudo nixos-rebuild switch
```
2. **Check service status:**
```bash
systemctl status lnbits
```
3. **Access the web interface:**
```
http://your-server-ip:5000
```
4. **Follow the first-time setup wizard** to configure your Lightning backend and create your first wallet.
5. **Bonus** Add Reverse Proxy with generated SSL Cert
```nix
{
# Enable nginx
services.nginx = {
enable = true;
virtualHosts."lnbits.mydomain.com" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://127.0.0.1:5000";
proxyWebsockets = true;
};
};
};
}
```
## Troubleshooting
### Service won't start
```bash
# Check service logs
journalctl -u lnbits -f
# Check if port is available
ss -tlnp | grep 5000
```
### Can't access web interface
- Ensure `openFirewall = true` is set
- Check if the port is correct: `services.lnbits.port`
- Verify host binding: `services.lnbits.host = "0.0.0.0"`
## Further Reading
- [LNBits Documentation](https://docs.lnbits.org)
- [Lightning Wallet Configuration](https://docs.lnbits.org/guide/wallets.html)
- [LNBits Extensions](https://docs.lnbits.org/devs/extensions.html)

View file

@ -35,7 +35,7 @@ in
type = types.path;
default = "/var/lib/lnbits";
description = ''
The lnbits state directory which LNBITS_DATA_FOLDER will be set to
The lnbits state directory
'';
};
host = mkOption {
@ -90,6 +90,7 @@ in
systemd.tmpfiles.rules = [
"d ${cfg.stateDir} 0700 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/data 0700 ${cfg.user} ${cfg.group} - -"
];
systemd.services.lnbits = {
@ -99,18 +100,18 @@ in
after = [ "network-online.target" ];
environment = lib.mkMerge [
{
LNBITS_DATA_FOLDER = "${cfg.stateDir}";
LNBITS_EXTENSIONS_PATH = "${cfg.stateDir}/extensions";
LNBITS_PATH = "${cfg.package.src}";
LNBITS_DATA_FOLDER = "${cfg.stateDir}/data";
# LNBits automatically appends '/extensions' to this path
LNBITS_EXTENSIONS_PATH = "${cfg.stateDir}";
}
cfg.env
];
serviceConfig = {
User = cfg.user;
Group = cfg.group;
WorkingDirectory = "${cfg.package.src}";
StateDirectory = "${cfg.stateDir}";
ExecStart = "${lib.getExe cfg.package} --port ${toString cfg.port} --host ${cfg.host}";
WorkingDirectory = "${cfg.package}/lib/python3.12/site-packages";
StateDirectory = "lnbits";
ExecStart = "${cfg.package}/bin/lnbits --port ${toString cfg.port} --host ${cfg.host}";
Restart = "always";
PrivateTmp = true;
};