Compare commits

..

No commits in common. "main" and "v1.0.1" have entirely different histories.

20 changed files with 2786 additions and 2721 deletions

View file

@ -11,9 +11,14 @@ jobs:
tests:
runs-on: ubuntu-latest
needs: [lint]
strategy:
matrix:
python-version: ['3.9', '3.10']
steps:
- uses: actions/checkout@v4
- uses: lnbits/lnbits/.github/actions/prepare@dev
with:
python-version: ${{ matrix.python-version }}
- name: Run pytest
uses: pavelzw/pytest-action@v2
env:
@ -25,5 +30,5 @@ jobs:
job-summary: true
emoji: false
click-to-expand: true
custom-pytest: uv run pytest
report-title: 'test'
custom-pytest: poetry run pytest
report-title: 'test (${{ matrix.python-version }})'

View file

@ -5,27 +5,27 @@ format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier
prettier:
uv run ./node_modules/.bin/prettier --write .
poetry run ./node_modules/.bin/prettier --write .
pyright:
uv run ./node_modules/.bin/pyright
poetry run ./node_modules/.bin/pyright
mypy:
uv run mypy .
poetry run mypy .
black:
uv run black .
poetry run black .
ruff:
uv run ruff check . --fix
poetry run ruff check . --fix
checkruff:
uv run ruff check .
poetry run ruff check .
checkprettier:
uv run ./node_modules/.bin/prettier --check .
poetry run ./node_modules/.bin/prettier --check .
checkblack:
uv run black --check .
poetry run black --check .
checkeditorconfig:
editorconfig-checker
@ -34,15 +34,15 @@ test:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest
poetry run pytest
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with uv run pre-commit uninstall"
uv run pre-commit install
@echo "Uninstall the hook with poetry run pre-commit uninstall"
poetry run pre-commit install
pre-commit:
uv run pre-commit run --all-files
poetry run pre-commit run --all-files
checkbundle:

View file

@ -15,17 +15,11 @@
## Supported NIPs
- [x] **NIP-01**: Basic protocol flow
- [x] Regular Events
- [x] Replaceable Events (kinds 10000-19999)
- [x] Ephemeral Events (kinds 20000-29999)
- [x] Addressable Events (kinds 30000-39999)
- [x] **NIP-02**: Contact List and Petnames
- `kind: 3`: delete past contact lists as soon as the relay receives a new one
- [x] **NIP-04**: Encrypted Direct Message
- if `AUTH` enabled: send only to the intended target
- [x] **NIP-09**: Event Deletion
- [x] 'e' tags: Delete regular events by event ID
- [x] 'a' tags: Delete addressable events by address (kind:pubkey:d-identifier)
- [x] **NIP-11**: Relay Information Document
- > **Note**: the endpoint is NOT on the root level of the domain. It also includes a path (eg https://lnbits.link/nostrrelay/)
- [ ] **NIP-12**: Generic Tag Queries
@ -35,12 +29,6 @@
- [x] Regular Events
- [x] Replaceable Events
- [x] Ephemeral Events
- [x] **NIP-17**: Private Direct Messages
- `kind: 1059` gift wraps stored and broadcast like regular events
- if `AUTH` enabled for `kind: 1059`: deliver only to the recipient
named in the `p` tag (same gating as NIP-04)
- encryption (NIP-44) and wrapping (NIP-59) are client-side concerns;
the relay handles transport only
- [x] **NIP-20**: Command Results
- todo: use correct prefixes
- [x] **NIP-22**: Event created_at Limits
@ -48,18 +36,14 @@
- not planned
- [x] **NIP-28** Public Chat
- `kind: 41`: handled similar to `kind 0` metadata events
- [x] **NIP-33**: Addressable Events (moved to NIP-01)
- ✅ Implemented as part of NIP-01 addressable events
- [ ] **NIP-33**: Parameterized Replaceable Events
- todo
- [ ] **NIP-40**: Expiration Timestamp
- todo
- [x] **NIP-42**: Authentication of clients to relays
- todo: use correct prefix
- [x] **NIP-44**: Encrypted Payloads (Versioned)
- relay treats payloads as opaque; encryption is client-side
- [ ] **NIP-50**: Search Capability
- todo
- [x] **NIP-59**: Gift Wrap
- `kind: 13` (seal) and `kind: 1059` (gift wrap) accepted; unwrapping is client-side
## Create Relay

View file

@ -1,9 +1,8 @@
{
"name": "Nostr Relay",
"version": "1.1.0",
"short_description": "One click launch your own relay!",
"tile": "/nostrrelay/static/image/nostrrelay.png",
"min_lnbits_version": "1.4.0",
"min_lnbits_version": "1.0.0",
"contributors": [
{
"name": "motorina0",

26
crud.py
View file

@ -1,4 +1,5 @@
import json
from typing import Optional
from lnbits.db import Database
@ -20,7 +21,7 @@ async def update_relay(relay: NostrRelay) -> NostrRelay:
return relay
async def get_relay(user_id: str, relay_id: str) -> NostrRelay | None:
async def get_relay(user_id: str, relay_id: str) -> Optional[NostrRelay]:
return await db.fetchone(
"SELECT * FROM nostrrelay.relays WHERE user_id = :user_id AND id = :id",
{"user_id": user_id, "id": relay_id},
@ -28,7 +29,7 @@ async def get_relay(user_id: str, relay_id: str) -> NostrRelay | None:
)
async def get_relay_by_id(relay_id: str) -> NostrRelay | None:
async def get_relay_by_id(relay_id: str) -> Optional[NostrRelay]:
"""Note: it does not require `user_id`. Can read any relay. Use it with care."""
return await db.fetchone(
"SELECT * FROM nostrrelay.relays WHERE id = :id",
@ -57,7 +58,7 @@ async def get_config_for_all_active_relays() -> dict:
return active_relay_configs
async def get_public_relay(relay_id: str) -> dict | None:
async def get_public_relay(relay_id: str) -> Optional[dict]:
relay = await db.fetchone(
"SELECT * FROM nostrrelay.relays WHERE id = :id",
{"id": relay_id},
@ -129,7 +130,7 @@ async def get_events(
return events
async def get_event(relay_id: str, event_id: str) -> NostrEvent | None:
async def get_event(relay_id: str, event_id: str) -> Optional[NostrEvent]:
event = await db.fetchone(
"SELECT * FROM nostrrelay.events WHERE relay_id = :relay_id AND id = :id",
{"relay_id": relay_id, "id": event_id},
@ -192,20 +193,9 @@ async def mark_events_deleted(relay_id: str, nostr_filter: NostrFilter):
async def delete_events(relay_id: str, nostr_filter: NostrFilter):
if nostr_filter.is_empty():
return None
inner_joins, where, values = nostr_filter.to_sql_components(relay_id)
if inner_joins:
# Use subquery for DELETE operations with JOINs
subquery = f"""
SELECT nostrrelay.events.id FROM nostrrelay.events
{" ".join(inner_joins)}
WHERE {" AND ".join(where)}
"""
query = f"DELETE FROM nostrrelay.events WHERE id IN ({subquery})"
else:
# Simple DELETE without JOINs
query = f"DELETE FROM nostrrelay.events WHERE {' AND '.join(where)}"
_, where, values = nostr_filter.to_sql_components(relay_id)
query = f"DELETE from nostrrelay.events WHERE {' AND '.join(where)}"
await db.execute(query, values)
# todo: delete tags
@ -285,7 +275,7 @@ async def delete_account(relay_id: str, pubkey: str):
async def get_account(
relay_id: str,
pubkey: str,
) -> NostrAccount | None:
) -> Optional[NostrAccount]:
return await db.fetchone(
"""
SELECT * FROM nostrrelay.accounts

View file

@ -1,3 +1,5 @@
from typing import Optional
from pydantic import BaseModel
@ -14,8 +16,8 @@ class BuyOrder(BaseModel):
class NostrPartialAccount(BaseModel):
relay_id: str
pubkey: str
allowed: bool | None = None
blocked: bool | None = None
allowed: Optional[bool] = None
blocked: Optional[bool] = None
class NostrAccount(BaseModel):
@ -42,4 +44,4 @@ class NostrEventTags(BaseModel):
event_id: str
name: str
value: str
extra: str | None = None
extra: Optional[str] = None

2629
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,34 +1,40 @@
[project]
name = "nostrrelay"
version = "1.1.0"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/nostrrelay" }
dependencies = [ "lnbits>1" ]
[tool.poetry]
package-mode = false
name = "nostrrelay"
version = "0.0.0"
description = "nostrrelay"
authors = ["dni <dni@lnbits.com>"]
[dependency-groups]
dev= [
"black",
"pytest-asyncio",
"pytest",
"mypy==1.17.1",
"pre-commit",
"ruff",
"pytest-md",
]
[tool.poetry.dependencies]
python = "^3.10 | ^3.9"
lnbits = {allow-prereleases = true, version = "*"}
[tool.poetry.group.dev.dependencies]
black = "^24.3.0"
pytest-asyncio = "^0.21.0"
pytest = "^7.3.2"
mypy = "^1.5.1"
pre-commit = "^3.2.2"
ruff = "^0.3.2"
pytest-md = "^0.2.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.mypy]
plugins = ["pydantic.mypy"]
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = true
exclude = [
"boltz_client"
]
[[tool.mypy.overrides]]
module = [
"lnbits.*",
"loguru.*",
"fastapi.*",
"pydantic.*",
"embit.*",
"secp256k1.*",
]
ignore_missing_imports = "True"
[tool.pytest.ini_options]
log_cli = false
@ -80,8 +86,8 @@ classmethod-decorators = [
# [tool.ruff.lint.extend-per-file-ignores]
# "views_api.py" = ["F401"]
[tool.ruff.lint.mccabe]
max-complexity = 11
# [tool.ruff.lint.mccabe]
# max-complexity = 10
[tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.

View file

@ -1,7 +1,6 @@
import json
import time
from collections.abc import Awaitable, Callable
from typing import Any
from typing import Any, Awaitable, Callable, List, Optional
from fastapi import WebSocket
from lnbits.helpers import urlsafe_short_hash
@ -26,17 +25,17 @@ class NostrClientConnection:
def __init__(self, relay_id: str, websocket: WebSocket):
self.websocket = websocket
self.relay_id = relay_id
self.filters: list[NostrFilter] = []
self.auth_pubkey: str | None = None # set if authenticated
self._auth_challenge: str | None = None
self.filters: List[NostrFilter] = []
self.auth_pubkey: Optional[str] = None # set if authenticated
self._auth_challenge: Optional[str] = None
self._auth_challenge_created_at = 0
self.event_validator = EventValidator(self.relay_id)
self.broadcast_event: (
Callable[[NostrClientConnection, NostrEvent], Awaitable[None]] | None
) = None
self.get_client_config: Callable[[], RelaySpec] | None = None
self.broadcast_event: Optional[
Callable[[NostrClientConnection, NostrEvent], Awaitable[None]]
] = None
self.get_client_config: Optional[Callable[[], RelaySpec]] = None
async def start(self):
await self.websocket.accept()
@ -51,7 +50,7 @@ class NostrClientConnection:
except Exception as e:
logger.warning(e)
async def stop(self, reason: str | None):
async def stop(self, reason: Optional[str]):
message = reason if reason else "Server closed webocket"
try:
await self._send_msg(["NOTICE", message])
@ -69,7 +68,7 @@ class NostrClientConnection:
self.event_validator.get_client_config = get_client_config
async def notify_event(self, event: NostrEvent) -> bool:
if self._is_private_event_for_other(event):
if self._is_direct_message_for_other(event):
return False
for nostr_filter in self.filters:
@ -77,20 +76,15 @@ class NostrClientConnection:
resp = event.serialize_response(nostr_filter.subscription_id)
await self._send_msg(resp)
return True
else:
logger.info(
f"[NOSTRRELAY CLIENT] ❌ Filter didn't match for event {event.id}"
)
return False
def _is_private_event_for_other(self, event: NostrEvent) -> bool:
def _is_direct_message_for_other(self, event: NostrEvent) -> bool:
"""
p-tagged events that carry a single intended recipient (NIP-04 kind 4
direct messages and NIP-17 kind 1059 gift wraps) should not be
broadcast to arbitrary subscribers when the relay enforces AUTH for
that kind. Deliver only to the AUTH'd recipient named in a `p` tag.
Direct messages are not inteded to be boradcast (even if encrypted).
If the server requires AUTH for kind '4' then direct message will be
sent only to the intended client.
"""
if not event.is_private_message:
if not event.is_direct_message:
return False
if not self.config.event_requires_auth(event.kind):
return False
@ -103,12 +97,8 @@ class NostrClientConnection:
async def _broadcast_event(self, e: NostrEvent):
if self.broadcast_event:
await self.broadcast_event(self, e)
else:
logger.warning(
f"[NOSTRRELAY CLIENT] ❌ No broadcast_event callback available for event {e.id}"
)
async def _handle_message(self, data: list) -> list:
async def _handle_message(self, data: List) -> List:
if len(data) < 2:
return []
@ -122,24 +112,12 @@ class NostrClientConnection:
}
event = NostrEvent(**event_dict)
# Set the size field from the size_bytes property
event.size = event.size_bytes
await self._handle_event(event)
return []
if message_type == NostrEventType.REQ:
if len(data) < 3:
if len(data) != 3:
return []
subscription_id = data[1]
# Handle multiple filters in REQ message
# First remove existing filters for this subscription_id
self._remove_filter(subscription_id)
responses = []
for filter_data in data[2:]:
response = await self._handle_request(
subscription_id, NostrFilter.parse_obj(filter_data)
)
responses.extend(response)
return responses
return await self._handle_request(data[1], NostrFilter.parse_obj(data[2]))
if message_type == NostrEventType.CLOSE:
self._handle_close(data[1])
if message_type == NostrEventType.AUTH:
@ -149,7 +127,7 @@ class NostrClientConnection:
async def _handle_event(self, e: NostrEvent):
logger.info(f"nostr event: [{e.kind}, {e.pubkey}, '{e.content}']")
resp_nip20: list[Any] = ["OK", e.id]
resp_nip20: List[Any] = ["OK", e.id]
if e.is_auth_response_event:
valid, message = self.event_validator.validate_auth_event(
@ -182,19 +160,6 @@ class NostrClientConnection:
self.relay_id,
NostrFilter(kinds=[e.kind], authors=[e.pubkey], until=e.created_at),
)
if e.is_addressable_event:
# Extract 'd' tag value for addressable replacement (NIP-01)
d_tag_value = next((t[1] for t in e.tags if t[0] == "d"), None)
if d_tag_value:
deletion_filter = NostrFilter(
kinds=[e.kind],
authors=[e.pubkey],
**{"#d": [d_tag_value]}, # type: ignore
until=e.created_at,
)
await delete_events(self.relay_id, deletion_filter)
if not e.is_ephemeral_event:
await create_event(e)
await self._broadcast_event(e)
@ -217,73 +182,20 @@ class NostrClientConnection:
raise Exception("Client not ready!")
return self.get_client_config()
async def _send_msg(self, data: list):
async def _send_msg(self, data: List):
await self.websocket.send_text(json.dumps(data))
async def _handle_delete_event(self, event: NostrEvent):
# NIP 09 - Handle both regular events (e tags) and parameterized replaceable events (a tags)
# Get event IDs from 'e' tags (for regular events)
event_ids = [t[1] for t in event.tags if t[0] == "e"]
# Get event addresses from 'a' tags (for parameterized replaceable events)
event_addresses = [t[1] for t in event.tags if t[0] == "a"]
ids_to_delete = []
# Handle regular event deletions (e tags)
if event_ids:
nostr_filter = NostrFilter(authors=[event.pubkey], ids=event_ids)
# NIP 09
nostr_filter = NostrFilter(authors=[event.pubkey])
nostr_filter.ids = [t[1] for t in event.tags if t[0] == "e"]
events_to_delete = await get_events(self.relay_id, nostr_filter, False)
ids_to_delete.extend(
[e.id for e in events_to_delete if not e.is_delete_event]
)
# Handle parameterized replaceable event deletions (a tags)
if event_addresses:
for addr in event_addresses:
# Parse address format: kind:pubkey:d-tag
parts = addr.split(":")
if len(parts) == 3:
kind_str, addr_pubkey, d_tag = parts
try:
kind = int(kind_str)
# Only delete if the address pubkey matches the deletion event author
if addr_pubkey == event.pubkey:
# NOTE: Use "#d" alias, not "d" directly (Pydantic Field alias)
nostr_filter = NostrFilter(
authors=[addr_pubkey],
kinds=[kind],
**{"#d": [d_tag]}, # Use alias to set d field
)
events_to_delete = await get_events(
self.relay_id, nostr_filter, False
)
ids_to_delete.extend(
[
e.id
for e in events_to_delete
if not e.is_delete_event
]
)
else:
logger.warning(
f"Deletion request pubkey mismatch: {addr_pubkey} != {event.pubkey}"
)
except ValueError:
logger.warning(f"Invalid kind in address: {addr}")
else:
logger.warning(
f"Invalid address format (expected kind:pubkey:d-tag): {addr}"
)
# Only mark events as deleted if we found specific IDs
if ids_to_delete:
await mark_events_deleted(self.relay_id, NostrFilter(ids=ids_to_delete))
ids = [e.id for e in events_to_delete if not e.is_delete_event]
await mark_events_deleted(self.relay_id, NostrFilter(ids=ids))
async def _handle_request(
self, subscription_id: str, nostr_filter: NostrFilter
) -> list:
) -> List:
if self.config.require_auth_filter:
if not self.auth_pubkey:
return [["AUTH", self._current_auth_challenge()]]
@ -306,7 +218,8 @@ class NostrClientConnection:
return [["NOTICE", f"This is a paid relay: '{self.relay_id}'"]]
nostr_filter.subscription_id = subscription_id
if not self._can_add_filter():
self._remove_filter(subscription_id)
if self._can_add_filter():
max_filters = self.config.max_client_filters
return [
[
@ -318,7 +231,7 @@ class NostrClientConnection:
nostr_filter.enforce_limit(self.config.limit_per_filter)
self.filters.append(nostr_filter)
events = await get_events(self.relay_id, nostr_filter)
events = [e for e in events if not self._is_private_event_for_other(e)]
events = [e for e in events if not self._is_direct_message_for_other(e)]
serialized_events = [
event.serialize_response(subscription_id) for event in events
]
@ -337,8 +250,8 @@ class NostrClientConnection:
def _can_add_filter(self) -> bool:
return (
self.config.max_client_filters == 0
or len(self.filters) < self.config.max_client_filters
self.config.max_client_filters != 0
and len(self.filters) >= self.config.max_client_filters
)
def _auth_challenge_expired(self):

View file

@ -1,3 +1,5 @@
from typing import List
from ..crud import get_config_for_all_active_relays
from .client_connection import NostrClientConnection
from .event import NostrEvent
@ -45,7 +47,7 @@ class NostrClientManager:
def get_relay_config(self, relay_id: str) -> RelaySpec:
return self._active_relays[relay_id]
def clients(self, relay_id: str) -> list[NostrClientConnection]:
def clients(self, relay_id: str) -> List[NostrClientConnection]:
if relay_id not in self._clients:
self._clients[relay_id] = []
return self._clients[relay_id]

View file

@ -2,8 +2,8 @@ import hashlib
import json
from enum import Enum
from coincurve import PublicKeyXOnly
from pydantic import BaseModel, Field
from secp256k1 import PublicKey
class NostrEventType(str, Enum):
@ -23,7 +23,6 @@ class NostrEvent(BaseModel):
tags: list[list[str]] = Field(default=[], no_database=True)
content: str = ""
sig: str
size: int = 0
def nostr_dict(self) -> dict:
_nostr_dict = dict(self)
@ -64,21 +63,6 @@ class NostrEvent(BaseModel):
def is_delete_event(self) -> bool:
return self.kind == 5
@property
def is_seal(self) -> bool:
return self.kind == 13
@property
def is_gift_wrap(self) -> bool:
return self.kind == 1059
@property
def is_private_message(self) -> bool:
# Kinds whose payload addresses a single recipient via a `p` tag and is
# not meant to be broadcast to other subscribers when AUTH is enforced.
# NIP-04 (kind 4) and NIP-17 (kind 1059 gift wrap).
return self.is_direct_message or self.is_gift_wrap
@property
def is_regular_event(self) -> bool:
return self.kind >= 1000 and self.kind < 10000
@ -87,10 +71,6 @@ class NostrEvent(BaseModel):
def is_ephemeral_event(self) -> bool:
return self.kind >= 20000 and self.kind < 30000
@property
def is_addressable_event(self) -> bool:
return self.kind >= 30000 and self.kind < 40000
def check_signature(self):
event_id = self.event_id
if self.id != event_id:
@ -98,15 +78,14 @@ class NostrEvent(BaseModel):
f"Invalid event id. Expected: '{event_id}' got '{self.id}'"
)
try:
pub_key = PublicKeyXOnly(bytes.fromhex(self.pubkey))
pub_key = PublicKey(bytes.fromhex("02" + self.pubkey), True)
except Exception as exc:
raise ValueError(
f"Invalid public key: '{self.pubkey}' for event '{self.id}'"
) from exc
valid_signature = pub_key.verify(
bytes.fromhex(self.sig),
bytes.fromhex(event_id),
valid_signature = pub_key.schnorr_verify(
bytes.fromhex(event_id), bytes.fromhex(self.sig), None, raw=True
)
if not valid_signature:
raise ValueError(f"Invalid signature: '{self.sig}' for event '{self.id}'")

View file

@ -1,5 +1,5 @@
import time
from collections.abc import Callable
from typing import Callable, Optional, Tuple
from ..crud import get_account, get_storage_for_public_key, prune_old_events
from ..helpers import extract_domain
@ -15,11 +15,11 @@ class EventValidator:
self._last_event_timestamp = 0 # in hours
self._event_count_per_timestamp = 0
self.get_client_config: Callable[[], RelaySpec] | None = None
self.get_client_config: Optional[Callable[[], RelaySpec]] = None
async def validate_write(
self, e: NostrEvent, publisher_pubkey: str
) -> tuple[bool, str]:
) -> Tuple[bool, str]:
valid, message = self._validate_event(e)
if not valid:
return (valid, message)
@ -34,8 +34,8 @@ class EventValidator:
return True, ""
def validate_auth_event(
self, e: NostrEvent, auth_challenge: str | None
) -> tuple[bool, str]:
self, e: NostrEvent, auth_challenge: Optional[str]
) -> Tuple[bool, str]:
valid, message = self._validate_event(e)
if not valid:
return (valid, message)
@ -59,7 +59,7 @@ class EventValidator:
raise Exception("EventValidator not ready!")
return self.get_client_config()
def _validate_event(self, e: NostrEvent) -> tuple[bool, str]:
def _validate_event(self, e: NostrEvent) -> Tuple[bool, str]:
if self._exceeded_max_events_per_hour():
return False, "Exceeded max events per hour limit'!"
@ -76,7 +76,7 @@ class EventValidator:
async def _validate_storage(
self, pubkey: str, event_size_bytes: int
) -> tuple[bool, str]:
) -> Tuple[bool, str]:
if self.config.is_read_only_relay:
return False, "Cannot write event, relay is read-only"
@ -124,7 +124,7 @@ class EventValidator:
return self._event_count_per_timestamp > self.config.max_events_per_hour
def _created_at_in_range(self, created_at: int) -> tuple[bool, str]:
def _created_at_in_range(self, created_at: int) -> Tuple[bool, str]:
current_time = round(time.time())
if self.config.created_at_in_past != 0:
if created_at < (current_time - self.config.created_at_in_past):

View file

@ -1,3 +1,5 @@
from typing import Optional
from pydantic import BaseModel, Field
from .event import NostrEvent
@ -6,14 +8,13 @@ from .event import NostrEvent
class NostrFilter(BaseModel):
e: list[str] = Field(default=[], alias="#e")
p: list[str] = Field(default=[], alias="#p")
d: list[str] = Field(default=[], alias="#d")
ids: list[str] = []
authors: list[str] = []
kinds: list[int] = []
subscription_id: str | None = None
since: int | None = None
until: int | None = None
limit: int | None = None
subscription_id: Optional[str] = None
since: Optional[int] = None
until: Optional[int] = None
limit: Optional[int] = None
def matches(self, e: NostrEvent) -> bool:
# todo: starts with
@ -29,12 +30,9 @@ class NostrFilter(BaseModel):
if self.until and self.until > 0 and e.created_at > self.until:
return False
# Check tag filters - only fail if filter is specified and no match found
if not self.tag_in_list(e.tags, "e"):
return False
if not self.tag_in_list(e.tags, "p"):
return False
if not self.tag_in_list(e.tags, "d"):
found_e_tag = self.tag_in_list(e.tags, "e")
found_p_tag = self.tag_in_list(e.tags, "p")
if not found_e_tag or not found_p_tag:
return False
return True
@ -89,17 +87,6 @@ class NostrFilter(BaseModel):
)
where.append(f" p_tags.value in ({p_s}) AND p_tags.name = 'p'")
if len(self.d):
d_s = ",".join([f"'{d}'" for d in self.d])
d_join = (
"INNER JOIN nostrrelay.event_tags d_tags "
"ON nostrrelay.events.id = d_tags.event_id"
)
d_where = f" d_tags.value in ({d_s}) AND d_tags.name = 'd'"
inner_joins.append(d_join)
where.append(d_where)
if len(self.ids) != 0:
ids = ",".join([f"'{_id}'" for _id in self.ids])
where.append(f"id IN ({ids})")

View file

@ -1,3 +1,5 @@
from typing import Optional
from pydantic import BaseModel, Field
@ -98,11 +100,11 @@ class RelaySpec(RelayPublicSpec, WalletSpec, AuthSpec):
class NostrRelay(BaseModel):
id: str
user_id: str | None = None
user_id: Optional[str] = None
name: str
description: str | None = None
pubkey: str | None = None
contact: str | None = None
description: Optional[str] = None
pubkey: Optional[str] = None
contact: Optional[str] = None
active: bool = False
meta: RelaySpec = RelaySpec()
@ -116,7 +118,7 @@ class NostrRelay(BaseModel):
) -> dict:
return {
"contact": "https://t.me/lnbits",
"supported_nips": [1, 2, 4, 9, 11, 15, 16, 17, 20, 22, 28, 42, 44, 59],
"supported_nips": [1, 2, 4, 9, 11, 15, 16, 20, 22, 28, 42],
"software": "LNbits",
"version": "",
}

View file

@ -1,7 +1,7 @@
import asyncio
import inspect
from typing import List, Optional
import pytest
import pytest_asyncio
from lnbits.db import Database
from loguru import logger
@ -14,11 +14,11 @@ from .helpers import get_fixtures
class EventFixture(BaseModel):
name: str
exception: str | None
exception: Optional[str]
data: NostrEvent
@pytest.fixture(scope="session")
@pytest_asyncio.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop()
yield loop
@ -27,27 +27,22 @@ def event_loop():
@pytest_asyncio.fixture(scope="session", autouse=True)
async def migrate_db():
print("#### 999")
db = Database("ext_nostrrelay")
await db.execute("DROP TABLE IF EXISTS nostrrelay.events;")
await db.execute("DROP TABLE IF EXISTS nostrrelay.relays;")
await db.execute("DROP TABLE IF EXISTS nostrrelay.event_tags;")
await db.execute("DROP TABLE IF EXISTS nostrrelay.accounts;")
# check if exists else skip migrations
for key, migrate in inspect.getmembers(migrations, inspect.isfunction):
print("### 1000")
logger.info(f"Running migration '{key}'.")
await migrate(db)
yield db
return migrations
@pytest.fixture(scope="session")
def valid_events(migrate_db) -> list[EventFixture]:
@pytest_asyncio.fixture(scope="session")
def valid_events(migrate_db) -> List[EventFixture]:
data = get_fixtures("events")
return [EventFixture.parse_obj(e) for e in data["valid"]]
@pytest.fixture(scope="session")
def invalid_events(migrate_db) -> list[EventFixture]:
@pytest_asyncio.fixture(scope="session")
def invalid_events(migrate_db) -> List[EventFixture]:
data = get_fixtures("events")
return [EventFixture.parse_obj(e) for e in data["invalid"]]

View file

@ -1,5 +1,6 @@
import asyncio
from json import dumps, loads
from typing import Optional
import pytest
from fastapi import WebSocket
@ -40,7 +41,7 @@ class MockWebSocket(WebSocket):
async def wire_mock_data(self, data: dict):
await self.fake_wire.put(dumps(data))
async def close(self, code: int = 1000, reason: str | None = None) -> None:
async def close(self, code: int = 1000, reason: Optional[str] = None) -> None:
logger.info(f"{code}: {reason}")

View file

@ -1,4 +1,5 @@
import json
from typing import List
import pytest
from loguru import logger
@ -15,23 +16,23 @@ from .conftest import EventFixture
RELAY_ID = "r1"
def test_valid_event_id_and_signature(valid_events: list[EventFixture]):
def test_valid_event_id_and_signature(valid_events: List[EventFixture]):
for f in valid_events:
try:
f.data.check_signature()
except Exception as e:
logger.error(f"Invalid 'id' of 'signature' for fixture: '{f.name}'")
logger.error(f"Invalid 'id' ot 'signature' for fixture: '{f.name}'")
raise e
def test_invalid_event_id_and_signature(invalid_events: list[EventFixture]):
def test_invalid_event_id_and_signature(invalid_events: List[EventFixture]):
for f in invalid_events:
with pytest.raises(ValueError, match=f.exception):
f.data.check_signature()
@pytest.mark.asyncio
async def test_valid_event_crud(valid_events: list[EventFixture]):
async def test_valid_event_crud(valid_events: List[EventFixture]):
author = "a24496bca5dd73300f4e5d5d346c73132b7354c597fcbb6509891747b4689211"
event_id = "3219eec7427e365585d5adf26f5d2dd2709d3f0f2c0e1f79dc9021e951c67d96"
reply_event_id = "6b2b6cb9c72caaf3dfbc5baa5e68d75ac62f38ec011b36cc83832218c36e4894"
@ -64,7 +65,7 @@ async def get_by_id(data: NostrEvent, test_name: str):
), f"Restored event is different for fixture '{test_name}'"
async def filter_by_id(all_events: list[NostrEvent], data: NostrEvent, test_name: str):
async def filter_by_id(all_events: List[NostrEvent], data: NostrEvent, test_name: str):
nostr_filter = NostrFilter(ids=[data.id])
events = await get_events(RELAY_ID, nostr_filter)
@ -80,7 +81,7 @@ async def filter_by_id(all_events: list[NostrEvent], data: NostrEvent, test_name
), f"Filtered event is different for fixture '{test_name}'"
async def filter_by_author(all_events: list[NostrEvent], author):
async def filter_by_author(all_events: List[NostrEvent], author):
nostr_filter = NostrFilter(authors=[author])
events_by_author = await get_events(RELAY_ID, nostr_filter)
assert len(events_by_author) == 5, "Failed to query by authors"
@ -89,7 +90,7 @@ async def filter_by_author(all_events: list[NostrEvent], author):
assert len(filtered_events) == 5, "Failed to filter by authors"
async def filter_by_tag_p(all_events: list[NostrEvent], author):
async def filter_by_tag_p(all_events: List[NostrEvent], author):
# todo: check why constructor does not work for fields with aliases (#e, #p)
nostr_filter = NostrFilter()
nostr_filter.p.append(author)
@ -101,7 +102,7 @@ async def filter_by_tag_p(all_events: list[NostrEvent], author):
assert len(filtered_events) == 5, "Failed to filter by tag 'p'"
async def filter_by_tag_e(all_events: list[NostrEvent], event_id):
async def filter_by_tag_e(all_events: List[NostrEvent], event_id):
nostr_filter = NostrFilter()
nostr_filter.e.append(event_id)
@ -113,7 +114,7 @@ async def filter_by_tag_e(all_events: list[NostrEvent], event_id):
async def filter_by_tag_e_and_p(
all_events: list[NostrEvent], author, event_id, reply_event_id
all_events: List[NostrEvent], author, event_id, reply_event_id
):
nostr_filter = NostrFilter()
nostr_filter.p.append(author)
@ -133,7 +134,7 @@ async def filter_by_tag_e_and_p(
async def filter_by_tag_e_p_and_author(
all_events: list[NostrEvent], author, event_id, reply_event_id
all_events: List[NostrEvent], author, event_id, reply_event_id
):
nostr_filter = NostrFilter(authors=[author])
nostr_filter.p.append(author)

View file

@ -1,132 +0,0 @@
"""
Unit tests for NIP-17 (Private Direct Messages) handling.
Covers:
- kind 1059 (gift wrap) and kind 13 (seal) classification on NostrEvent
- the AUTH-gated private-recipient delivery rule in NostrClientConnection
"""
from unittest.mock import MagicMock
import pytest
from ..relay.client_connection import NostrClientConnection
from ..relay.event import NostrEvent
from ..relay.relay import RelaySpec
RELAY_ID = "relay_nip17"
RECIPIENT = "1111111111111111111111111111111111111111111111111111111111111111"
OTHER = "2222222222222222222222222222222222222222222222222222222222222222"
EPHEMERAL = "3333333333333333333333333333333333333333333333333333333333333333"
SIG = "0" * 128
def _gift_wrap_for(recipient: str) -> NostrEvent:
"""Build a kind 1059 event addressed to recipient. Skips signature validity."""
return NostrEvent(
id="0" * 64,
relay_id=RELAY_ID,
publisher=EPHEMERAL,
pubkey=EPHEMERAL,
created_at=0,
kind=1059,
tags=[["p", recipient]],
content="ciphertext",
sig=SIG,
)
def test_kind_classification_helpers():
seal = NostrEvent(
id="0" * 64,
relay_id=RELAY_ID,
publisher=OTHER,
pubkey=OTHER,
created_at=0,
kind=13,
tags=[],
content="",
sig=SIG,
)
wrap = _gift_wrap_for(RECIPIENT)
assert seal.is_seal
assert not seal.is_gift_wrap
assert not seal.is_private_message # seals carry no recipient metadata
assert wrap.is_gift_wrap
assert not wrap.is_seal
assert wrap.is_private_message
assert not wrap.is_ephemeral_event # nostrmarket relies on storage
def _make_connection(relay_spec: RelaySpec) -> NostrClientConnection:
conn = NostrClientConnection(relay_id=RELAY_ID, websocket=MagicMock())
conn.get_client_config = lambda: relay_spec
return conn
@pytest.mark.parametrize(
"force_auth,auth_pubkey,event_recipient,expected_filtered",
[
# AUTH not required for 1059 -> never filtered (matches NIP-04 default)
(False, None, RECIPIENT, False),
(False, RECIPIENT, RECIPIENT, False),
(False, OTHER, RECIPIENT, False),
# AUTH required for 1059 -> only the recipient gets it
(True, None, RECIPIENT, True),
(True, RECIPIENT, RECIPIENT, False),
(True, OTHER, RECIPIENT, True),
],
)
def test_gift_wrap_auth_gated_delivery(
force_auth, auth_pubkey, event_recipient, expected_filtered
):
spec = RelaySpec(forcedAuthEvents=[1059] if force_auth else [])
conn = _make_connection(spec)
conn.auth_pubkey = auth_pubkey
wrap = _gift_wrap_for(event_recipient)
assert conn._is_private_event_for_other(wrap) is expected_filtered
def test_kind_4_dm_still_gated_under_auth():
"""Regression: the NIP-04 gating behavior must remain identical."""
spec = RelaySpec(forcedAuthEvents=[4])
conn = _make_connection(spec)
conn.auth_pubkey = OTHER
dm = NostrEvent(
id="0" * 64,
relay_id=RELAY_ID,
publisher=RECIPIENT,
pubkey=RECIPIENT,
created_at=0,
kind=4,
tags=[["p", RECIPIENT]],
content="ciphertext",
sig=SIG,
)
assert conn._is_private_event_for_other(dm) is True
conn.auth_pubkey = RECIPIENT
assert conn._is_private_event_for_other(dm) is False
def test_non_private_kinds_never_filtered():
spec = RelaySpec(forcedAuthEvents=[1059, 4])
conn = _make_connection(spec)
conn.auth_pubkey = OTHER
note = NostrEvent(
id="0" * 64,
relay_id=RELAY_ID,
publisher=RECIPIENT,
pubkey=RECIPIENT,
created_at=0,
kind=1,
tags=[["p", RECIPIENT]],
content="hello",
sig=SIG,
)
assert conn._is_private_event_for_other(note) is False

2299
uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
from http import HTTPStatus
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket
from lnbits.core.crud import get_user
@ -137,7 +138,7 @@ async def api_get_relay_info() -> JSONResponse:
@nostrrelay_api_router.get("/api/v1/relay/{relay_id}")
async def api_get_relay(
relay_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
) -> NostrRelay | None:
) -> Optional[NostrRelay]:
relay = await get_relay(wallet.wallet.user, relay_id)
if not relay:
raise HTTPException(