Compare commits

...

10 commits

Author SHA1 Message Date
3c29099f3a add extensions.json for LNBITS_EXTENSIONS_MANIFESTS var
Some checks are pending
LNbits CI / test-api (, 3.11) (push) Blocked by required conditions
LNbits CI / test-api (, 3.12) (push) Blocked by required conditions
LNbits CI / test-api (postgres://lnbits:lnbits@0.0.0.0:5432/lnbits, 3.10) (push) Blocked by required conditions
LNbits CI / test-api (postgres://lnbits:lnbits@0.0.0.0:5432/lnbits, 3.11) (push) Blocked by required conditions
LNbits CI / test-api (postgres://lnbits:lnbits@0.0.0.0:5432/lnbits, 3.12) (push) Blocked by required conditions
LNbits CI / test-wallets (, 3.10) (push) Blocked by required conditions
LNbits CI / test-wallets (, 3.11) (push) Blocked by required conditions
LNbits CI / test-wallets (, 3.12) (push) Blocked by required conditions
LNbits CI / test-wallets (postgres://lnbits:lnbits@0.0.0.0:5432/lnbits, 3.10) (push) Blocked by required conditions
LNbits CI / test-wallets (postgres://lnbits:lnbits@0.0.0.0:5432/lnbits, 3.11) (push) Blocked by required conditions
LNbits CI / test-wallets (postgres://lnbits:lnbits@0.0.0.0:5432/lnbits, 3.12) (push) Blocked by required conditions
LNbits CI / test-unit (, 3.10) (push) Blocked by required conditions
LNbits CI / test-unit (, 3.11) (push) Blocked by required conditions
LNbits CI / test-unit (, 3.12) (push) Blocked by required conditions
LNbits CI / test-unit (postgres://lnbits:lnbits@0.0.0.0:5432/lnbits, 3.10) (push) Blocked by required conditions
LNbits CI / test-unit (postgres://lnbits:lnbits@0.0.0.0:5432/lnbits, 3.11) (push) Blocked by required conditions
LNbits CI / test-unit (postgres://lnbits:lnbits@0.0.0.0:5432/lnbits, 3.12) (push) Blocked by required conditions
LNbits CI / migration (3.10) (push) Blocked by required conditions
LNbits CI / migration (3.11) (push) Blocked by required conditions
LNbits CI / migration (3.12) (push) Blocked by required conditions
LNbits CI / openapi (push) Blocked by required conditions
LNbits CI / regtest (BoltzWallet, 3.10) (push) Blocked by required conditions
LNbits CI / regtest (CoreLightningRestWallet, 3.10) (push) Blocked by required conditions
LNbits CI / regtest (CoreLightningWallet, 3.10) (push) Blocked by required conditions
LNbits CI / regtest (EclairWallet, 3.10) (push) Blocked by required conditions
LNbits CI / regtest (LNbitsWallet, 3.10) (push) Blocked by required conditions
LNbits CI / regtest (LndRestWallet, 3.10) (push) Blocked by required conditions
LNbits CI / regtest (LndWallet, 3.10) (push) Blocked by required conditions
LNbits CI / jmeter (3.10) (push) Blocked by required conditions
codeql / analyze (push) Waiting to run
2025-12-31 13:37:55 +01:00
bdf71d3ea9 fix: use coincurve instead of secp256k1 for Nostr event signing
- Replace secp256k1.PrivateKey with coincurve.PrivateKey to match
  the sign_event function signature in lnbits/utils/nostr.py
- Remove internal try/except so exceptions propagate to caller,
  fixing misleading success logs when publishing actually fails

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:21:30 +01:00
0d579892a8 Adds API endpoint for default currency
Adds an API endpoint to retrieve the default accounting
currency configured for the LNbits instance. Returns the configured
default currency or None if not set.
2025-12-29 06:53:20 +01:00
44ba4e4086 Adds custom frontend URL redirect after auth
Allows redirecting users to a custom frontend URL after login, registration, or password reset.

This simplifies integrating LNbits with existing web applications by eliminating the need to handle authentication logic within the custom frontend.

Users are redirected to the configured URL after successful authentication.

This feature is backwards compatible and configurable via environment variable or admin UI.
2025-12-29 06:53:20 +01:00
2795ad9ac3 Change default lnurlp 2025-12-29 06:50:27 +01:00
7f66c4b092 FIX: create wallet variable to pass to lnurlp creation 2025-12-29 06:50:27 +01:00
7075abdbbd check this - normalize pubkey 2025-12-29 06:47:46 +01:00
26fcf50462 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-12-29 06:47:46 +01:00
5983774e22 misc docs/helpers 2025-12-29 06:45:51 +01:00
dffc54c0d2 feat: add default pay link creation for users with username in user account setup 2025-12-29 06:45:51 +01:00
13 changed files with 1354 additions and 8 deletions

View file

@ -0,0 +1,213 @@
# Custom Frontend URL - Simple Redirect Approach
## Overview
This is the **simplest** approach to integrate LNbits with a custom frontend. LNbits handles all authentication (login, register, password reset) and then redirects users to your custom frontend URL instead of `/wallet`.
## How It Works
### Password Reset Flow
1. **Admin generates reset link** → User receives: `https://lnbits.com/?reset_key=...`
2. **User clicks link** → LNbits shows password reset form
3. **User submits new password** → LNbits sets auth cookies
4. **LNbits redirects**`https://myapp.com/` (your custom frontend)
5. **Web-app loads**`checkAuth()` sees valid LNbits cookies → ✅ User is logged in!
### Login Flow
1. **User visits**`https://lnbits.com/`
2. **User logs in** → LNbits validates credentials and sets cookies
3. **LNbits redirects**`https://myapp.com/`
4. **Web-app loads** → ✅ User is logged in!
### Register Flow
1. **User visits**`https://lnbits.com/`
2. **User registers** → LNbits creates account and sets cookies
3. **LNbits redirects**`https://myapp.com/`
4. **Web-app loads** → ✅ User is logged in!
## Configuration
### Environment Variable
```bash
# In .env or environment
export LNBITS_CUSTOM_FRONTEND_URL=https://myapp.com
```
Or configure through LNbits admin UI: **Settings → Operations → Custom Frontend URL**
### Default Behavior
- **If not set**: Redirects to `/wallet` (default LNbits behavior)
- **If set**: Redirects to your custom frontend URL
## Implementation
### Changes Made
1. **Added setting** (`lnbits/settings.py:282-285`):
```python
class OpsSettings(LNbitsSettings):
lnbits_custom_frontend_url: str | None = Field(
default=None,
description="Custom frontend URL for post-auth redirects"
)
```
2. **Exposed to frontend** (`lnbits/helpers.py:88`):
```python
window_settings = {
# ...
"LNBITS_CUSTOM_FRONTEND_URL": settings.lnbits_custom_frontend_url,
# ...
}
```
3. **Updated redirects** (`lnbits/static/js/index.js`):
- `login()` - line 78
- `register()` - line 56
- `reset()` - line 68
- `loginUsr()` - line 88
All now use:
```javascript
window.location.href = this.LNBITS_CUSTOM_FRONTEND_URL || '/wallet'
```
## Advantages
### ✅ Zero Changes to Web-App
Your custom frontend doesn't need to:
- Parse `?reset_key=` from URLs
- Build password reset UI
- Handle password reset API calls
- Manage error states
- Implement validation
### ✅ Auth Cookies Work Automatically
LNbits sets httponly cookies that your web-app automatically sees:
- `cookie_access_token`
- `is_lnbits_user_authorized`
Your existing `auth.checkAuth()` will detect these and log the user in.
### ✅ Simple & Elegant
Only 3 files changed in LNbits, zero changes in web-app.
### ✅ Backwards Compatible
Existing LNbits installations continue to work. Setting is optional.
## Disadvantages
### ⚠️ Brief Branding Inconsistency
Users see LNbits UI briefly during:
- Login form
- Registration form
- Password reset form
Then get redirected to your branded web-app.
**This is usually acceptable** for most use cases, especially for password reset which is infrequent.
## Testing
1. **Set the environment variable**:
```bash
export LNBITS_CUSTOM_FRONTEND_URL=http://localhost:5173
```
2. **Restart LNbits**:
```bash
poetry run lnbits
```
3. **Test Login**:
- Visit `http://localhost:5000/`
- Log in with credentials
- Verify redirect to `http://localhost:5173`
- Verify web-app shows you as logged in
4. **Test Password Reset**:
- Admin generates reset link in users panel
- User clicks link with `?reset_key=...`
- User enters new password
- Verify redirect to custom frontend
- Verify web-app shows you as logged in
## Security
### ✅ Secure
- Auth cookies are httponly (can't be accessed by JavaScript)
- LNbits handles all auth logic
- No sensitive data in URLs except one-time reset keys
- Reset keys expire based on `auth_token_expire_minutes`
### 🔒 HTTPS Required
Always use HTTPS for custom frontend URLs in production:
```bash
export LNBITS_CUSTOM_FRONTEND_URL=https://myapp.com
```
## Migration
**No database migration required!**
Settings are stored as JSON in `system_settings` table. New fields are automatically included.
## Alternative: Full Custom Frontend Approach
If you need **complete branding consistency** (no LNbits UI shown), you would need to:
1. Build password reset form in web-app
2. Parse `?reset_key=` from URL
3. Add API method to call `/api/v1/auth/reset`
4. Handle validation, errors, loading states
5. Update admin UI to generate links pointing to web-app
This is **significantly more work** for marginal benefit (users see LNbits UI for ~5 seconds during password reset).
## Recommendation
**Use this simple approach** unless you have specific requirements for complete UI consistency. The brief LNbits UI is a small trade-off for the simplicity gained.
## Related Files
- `lnbits/settings.py` - Setting definition
- `lnbits/helpers.py` - Expose to frontend
- `lnbits/static/js/index.js` - Redirect logic
## Example `.env`
```bash
# LNbits Configuration
LNBITS_DATA_FOLDER=./data
LNBITS_DATABASE_URL=sqlite:///./data/database.sqlite3
# Custom Frontend Integration
LNBITS_CUSTOM_FRONTEND_URL=https://myapp.com
# Other settings...
```
## How Web-App Benefits
Your web-app at `https://myapp.com` can now:
1. **Receive logged-in users** from LNbits without any code changes
2. **Use existing `auth.checkAuth()`** - it just works
3. **Focus on your features** - don't rebuild auth UI
4. **Trust LNbits security** - it's battle-tested
The auth cookies LNbits sets are valid for your domain if LNbits is on a subdomain (e.g., `api.myapp.com`) or you're using proper CORS configuration.
## Future Enhancement
If you later need the full custom UI approach, all the groundwork is there:
- Setting exists and is configurable
- Just add the web-app UI components
- Update admin panel to generate web-app links
But start with this simple approach first! 🚀

113
extensions.json Normal file
View file

@ -0,0 +1,113 @@
{
"featured": [
"satmachineclient",
"castle"
],
"extensions": [
{
"id": "satmachineadmin",
"repo": "https://git.aiolabs.dev/lnbits-exts/satmachineadmin",
"name": "Satoshi Machine Admin",
"version": "0.0.2",
"short_description": "Admin Dashboard for Satoshi Machine",
"icon": "https://git.aiolabs.dev/lnbits-exts/satmachineadmin/raw/branch/main/static/image/aio.png",
"archive": "https://git.aiolabs.dev/lnbits-exts/satmachineadmin/archive/v0.0.2.zip",
"hash": "e46863d3c1133897d9fe9cc4afe149804df25b2aeee1716de4da4332ae9789b1"
},
{
"id": "satmachineclient",
"repo": "https://git.aiolabs.dev/lnbits-exts/satmachineclient",
"name": "Satoshi Machine Client",
"version": "0.0.1",
"short_description": "Client Dashboard for Satoshi Machine",
"icon": "https://git.aiolabs.dev/lnbits-exts/satmachineclient/raw/branch/main/static/image/aio.png",
"archive": "https://git.aiolabs.dev/lnbits-exts/satmachineclient/archive/v0.0.1.zip",
"hash": "6d93e1a537ca3565fcf4dc4811497de10660a14639046854c1006b06d9a045e5"
},
{
"id": "events",
"repo": "https://git.aiolabs.dev/lnbits-exts/events",
"name": "AIO Events",
"version": "0.0.1",
"short_description": "AIO fork of lnbits-exts/events",
"icon": "https://git.aiolabs.dev/lnbits-exts/events/raw/branch/main/static/image/aio.png",
"archive": "https://git.aiolabs.dev/lnbits-exts/events/archive/v0.0.1.zip",
"hash": "681408f3f5f5b0773f0aa5fd7fbdefcd47ed9190b44409b80d2b119cc5516335"
},
{
"id": "nostrrelay",
"repo": "https://git.aiolabs.dev/lnbits-exts/nostrrelay",
"name": "AIO Nostrrelay",
"version": "0.0.2",
"short_description": "AIO fork of lnbits-exts/nostrrelay",
"icon": "https://git.aiolabs.dev/lnbits-exts/nostrrelay/raw/branch/main/static/image/aio.png",
"archive": "https://git.aiolabs.dev/lnbits-exts/nostrrelay/archive/v0.0.2.zip",
"hash": "a46475f076557f5c5a426a9e3cbb680af855b833a9f66ba32b6f36778336302f"
},
{
"id": "nostrclient",
"repo": "https://git.aiolabs.dev/lnbits-exts/nostrclient",
"name": "AIO nostrclient",
"version": "0.0.1",
"short_description": "AIO fork of lnbits-exts/nostrclient",
"icon": "https://git.aiolabs.dev/lnbits-exts/nostrclient/raw/branch/main/static/image/aio.png",
"archive": "https://git.aiolabs.dev/lnbits-exts/nostrclient/archive/v0.0.1.zip",
"hash": "c71c6bd2105e299a2ff342fb9d94d619570f28f231a3025f52f29757f9b31d04"
},
{
"id": "nostrmarket",
"repo": "https://git.aiolabs.dev/lnbits-exts/nostrmarket",
"name": "AIO nostrmarket",
"version": "0.0.1",
"short_description": "AIO fork of lnbits-exts/nostrmarket",
"icon": "https://git.aiolabs.dev/lnbits-exts/nostrmarket/raw/branch/main/static/image/aio.png",
"archive": "https://git.aiolabs.dev/lnbits-exts/nostrmarket/archive/v0.0.1.zip",
"hash": "5177dfdae62554c75ba8244d5a26e371de6e512a8b6dc7bb6f90e8b05916ed35"
},
{
"id": "castle",
"repo": "https://git.aiolabs.dev/lnbits-exts/castle",
"name": "Castle",
"version": "0.0.1",
"short_description": "Castle Accounting",
"icon": "https://git.aiolabs.dev/lnbits-exts/castle/raw/branch/main/static/image/castle.png",
"archive": "https://git.aiolabs.dev/lnbits-exts/castle/archive/v0.0.1.zip",
"hash": "0b152190902e7377bfba9feb4c82998a9400a69413ee5482343f15da8308b89b"
},
{
"id": "castle",
"repo": "https://git.aiolabs.dev/lnbits-exts/castle",
"name": "Castle",
"version": "0.0.2",
"short_description": "Castle Accounting",
"icon": "https://git.aiolabs.dev/lnbits-exts/castle/raw/branch/main/static/image/castle.png",
"archive": "https://git.aiolabs.dev/lnbits-exts/castle/archive/v0.0.2.zip",
"hash": "0f742deb4777480a32ad239f6eae42c57583bf7a8ca9bd131903b6bb7282f069"
},
{
"id": "lnurlp",
"repo": "https://github.com/lnbits/lnurlp",
"name": "Pay Links",
"version": "1.1.3",
"min_lnbits_version": "1.3.0",
"short_description": "Make reusable LNURL pay links",
"icon": "https://github.com/lnbits/lnurlp/raw/main/static/image/lnurl-pay.png",
"details_link": "https://raw.githubusercontent.com/lnbits/lnurlp/main/config.json",
"archive": "https://github.com/lnbits/lnurlp/archive/refs/tags/v1.1.3.zip",
"hash": "913b6880fd824e801f05ae50ed6f57817c9a580f1ed1b02b435823d5754e1b98",
"max_lnbits_version": "1.4.0"
},
{
"id": "lnurlp",
"repo": "https://github.com/lnbits/lnurlp",
"name": "Pay Links",
"version": "1.2.0",
"min_lnbits_version": "1.4.0",
"short_description": "Make reusable LNURL pay links",
"icon": "https://github.com/lnbits/lnurlp/raw/main/static/image/lnurl-pay.png",
"details_link": "https://raw.githubusercontent.com/lnbits/lnurlp/main/config.json",
"archive": "https://github.com/lnbits/lnurlp/archive/refs/tags/v1.2.0.zip",
"hash": "d3872eb5f65b9e962fc201d6784f94f3fd576284582b980805142a2b3f62d8e8"
}
]
}

View file

@ -1,3 +1,5 @@
import json
import time
from pathlib import Path
from uuid import uuid4
@ -72,7 +74,7 @@ async def create_user_account_no_ckeck(
account.id = uuid4().hex
account = await create_account(account, conn=conn)
await create_wallet(
wallet = await create_wallet(
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
conn=conn,
@ -86,6 +88,22 @@ async def create_user_account_no_ckeck(
except Exception as e:
logger.error(f"Error enabeling default extension {ext_id}: {e}")
# Create default pay link for users with username
if account.username and "lnurlp" in user_extensions:
try:
await _create_default_pay_link(account, wallet)
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
if account.username and account.pubkey and account.prvkey:
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, conn=conn)
if not user:
raise ValueError("Cannot find user for account.")
@ -192,3 +210,160 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
async def _create_default_pay_link(account: Account, wallet) -> None:
"""Create a default pay link for new users with username (Bitcoinmat receiving address)"""
try:
# 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")
lnurlp_models = importlib.import_module("lnbits.extensions.lnurlp.models")
except ImportError:
# If that fails, try importing from external extensions path
# This handles cases where extensions are in /var/lib/lnbits/extensions
try:
# Add extensions path to sys.path if not already there
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,
# Note default `currency` is satoshis when set as NULL in db
min=1, # minimum 1 sat
max=500000, # maximum 500,000 sats
comment_chars=140,
username=account.username, # use the username as lightning address
zaps=True,
disposable=True,
)
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"""
import coincurve
from lnbits.utils.nostr import sign_event
# 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),
}
# Convert hex private key to coincurve PrivateKey
private_key = coincurve.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)
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

@ -100,6 +100,16 @@ async def api_list_currencies_available() -> list[str]:
return allowed_currencies()
@api_router.get("/api/v1/default-currency")
async def api_get_default_currency() -> dict[str, str | None]:
"""
Get the default accounting currency for this LNbits instance.
Returns the configured default, or None if not set.
"""
default_currency = settings.lnbits_default_accounting_currency
return {"default_currency": default_currency}
@api_router.post("/api/v1/conversion")
async def api_fiat_as_sats(data: ConversionData):
output = {}

View file

@ -96,6 +96,9 @@ async def api_create_user(data: CreateUser) -> CreateUser:
data.extra = data.extra or UserExtra()
data.extra.provider = data.extra.provider or "lnbits"
if data.pubkey:
data.pubkey = normalize_public_key(data.pubkey)
account = Account(
id=uuid4().hex,
username=data.username,

View file

@ -87,6 +87,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
"LNBITS_CUSTOM_IMAGE": settings.lnbits_custom_image,
"LNBITS_CUSTOM_BADGE": settings.lnbits_custom_badge,
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
"LNBITS_CUSTOM_FRONTEND_URL": settings.lnbits_custom_frontend_url,
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all,
"LNBITS_NEW_ACCOUNTS_ALLOWED": settings.new_accounts_allowed,
"LNBITS_NODE_UI": settings.lnbits_node_ui and settings.has_nodemanager,

View file

@ -960,6 +960,10 @@ class EnvSettings(LNbitsSettings):
lnbits_title: str = Field(default="LNbits API")
lnbits_path: str = Field(default=".")
lnbits_extensions_path: str = Field(default="lnbits")
lnbits_custom_frontend_url: str | None = Field(
default=None,
description="Custom frontend URL for redirects after auth (e.g., https://myapp.com). If not set, redirects to /wallet. This is read-only and must be set via environment variable."
)
super_user: str = Field(default="")
auth_secret_key: str = Field(default="")
version: str = Field(default="0.0.0")

View file

@ -79,7 +79,12 @@ window.PageHome = {
this.password,
this.passwordRepeat
)
this.refreshAuthUser()
// Redirect to custom frontend URL if configured, otherwise use router
if (this.LNBITS_CUSTOM_FRONTEND_URL) {
window.location.href = this.LNBITS_CUSTOM_FRONTEND_URL
} else {
this.refreshAuthUser()
}
} catch (e) {
LNbits.utils.notifyApiError(e)
}
@ -91,7 +96,12 @@ window.PageHome = {
this.password,
this.passwordRepeat
)
this.refreshAuthUser()
// Redirect to custom frontend URL if configured, otherwise use router
if (this.LNBITS_CUSTOM_FRONTEND_URL) {
window.location.href = this.LNBITS_CUSTOM_FRONTEND_URL
} else {
this.refreshAuthUser()
}
} catch (e) {
LNbits.utils.notifyApiError(e)
}
@ -99,7 +109,12 @@ window.PageHome = {
async login() {
try {
await LNbits.api.login(this.username, this.password)
this.refreshAuthUser()
// Redirect to custom frontend URL if configured, otherwise use router
if (this.LNBITS_CUSTOM_FRONTEND_URL) {
window.location.href = this.LNBITS_CUSTOM_FRONTEND_URL
} else {
this.refreshAuthUser()
}
} catch (e) {
LNbits.utils.notifyApiError(e)
}
@ -107,7 +122,13 @@ window.PageHome = {
async loginUsr() {
try {
await LNbits.api.loginUsr(this.usr)
this.refreshAuthUser()
this.usr = ''
// Redirect to custom frontend URL if configured, otherwise use router
if (this.LNBITS_CUSTOM_FRONTEND_URL) {
window.location.href = this.LNBITS_CUSTOM_FRONTEND_URL
} else {
this.refreshAuthUser()
}
} catch (e) {
console.warn(e)
LNbits.utils.notifyApiError(e)
@ -138,14 +159,28 @@ window.PageHome = {
}
},
created() {
if (this.g.isUserAuthorized) {
return this.refreshAuthUser()
}
const urlParams = new URLSearchParams(window.location.search)
// Check for reset_key FIRST - password reset should work even if user is logged in
this.reset_key = urlParams.get('reset_key')
if (this.reset_key) {
this.authAction = 'reset'
// Clear existing auth cookies to allow password reset to work
this.$q.cookies.remove('is_lnbits_user_authorized')
this.$q.cookies.remove('cookie_access_token')
return // Don't redirect to /wallet
}
// Redirect authorized users
if (this.g.isUserAuthorized) {
// Redirect to custom frontend URL if configured, otherwise use router
if (this.LNBITS_CUSTOM_FRONTEND_URL) {
window.location.href = this.LNBITS_CUSTOM_FRONTEND_URL
} else {
return this.refreshAuthUser()
}
}
// check if lightning parameters are present in the URL
if (urlParams.has('lightning')) {
this.lnurl = urlParams.get('lightning')

View file

@ -0,0 +1,88 @@
# LNBits Auto-Credit Changes
## Overview
Modified LNBits server to automatically credit new accounts with 1 million satoshis (1,000,000 sats) when they are created.
## Changes Made
### 1. Modified `lnbits/core/services/users.py`
**Added imports:**
- `get_wallet` from `..crud`
- `update_wallet_balance` from `.payments`
**Modified `create_user_account_no_ckeck` function:**
- Changed `create_wallet` call to capture the returned wallet object
- Added automatic credit of 1,000,000 sats after wallet creation
- Added error handling and logging for the credit operation
**Code changes:**
```python
# Before:
await create_wallet(
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
)
# After:
wallet = await create_wallet(
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
)
# Credit new account with 1 million satoshis
try:
await update_wallet_balance(wallet, 1_000_000)
logger.info(f"Credited new account {account.id} with 1,000,000 sats")
except Exception as e:
logger.error(f"Failed to credit new account {account.id} with 1,000,000 sats: {e}")
```
### 2. Updated Tests in `tests/api/test_auth.py`
**Modified test functions:**
- `test_register_ok`: Added balance verification for regular user registration
- `test_register_nostr_ok`: Added balance verification for Nostr authentication
**Added assertions:**
```python
# Check that the wallet has 1 million satoshis
wallet = user.wallets[0]
assert wallet.balance == 1_000_000, f"Expected 1,000,000 sats balance, got {wallet.balance} sats"
```
## Affected Account Creation Paths
The automatic credit will be applied to all new accounts created through:
1. **Regular user registration** (`/api/v1/auth/register`)
2. **Nostr authentication** (`/api/v1/auth/nostr`)
3. **SSO login** (when new account is created)
4. **API account creation** (`/api/v1/account`)
5. **Admin user creation** (via admin interface)
## Excluded Paths
- **Superuser/Admin account creation** (`init_admin_settings`): This function creates the admin account directly and bypasses the user creation flow, so it won't receive the automatic credit.
## Testing
To test the changes:
1. Install dependencies: `poetry install`
2. Run the modified tests: `poetry run pytest tests/api/test_auth.py::test_register_ok -v`
3. Run Nostr test: `poetry run pytest tests/api/test_auth.py::test_register_nostr_ok -v`
## Logging
The system will log:
- Success: `"Credited new account {account.id} with 1,000,000 sats"`
- Failure: `"Failed to credit new account {account.id} with 1,000,000 sats: {error}"`
## Notes
- The credit uses the existing `update_wallet_balance` function which creates an internal payment record
- The credit is applied after wallet creation but before user extensions are set up
- Error handling ensures that account creation continues even if the credit fails
- The credit amount is hardcoded to 1,000,000 sats (1MM sats)

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)