diff --git a/__init__.py b/__init__.py index cffa9fa..921c383 100644 --- a/__init__.py +++ b/__init__.py @@ -27,11 +27,7 @@ def nostrmarket_renderer(): nostr_client: NostrClient = NostrClient() -from .tasks import ( # noqa - subscription_health_monitor, - wait_for_nostr_events, - wait_for_paid_invoices, -) +from .tasks import wait_for_nostr_events, wait_for_paid_invoices # noqa from .views import * # noqa from .views_api import * # noqa @@ -69,13 +65,4 @@ def nostrmarket_start(): task3 = create_permanent_unique_task( "ext_nostrmarket_wait_for_events", _wait_for_nostr_events ) - - async def _health_monitor(): - # start after the subscription is active - await asyncio.sleep(20) - await subscription_health_monitor(nostr_client) - - task4 = create_permanent_unique_task( - "ext_nostrmarket_health_monitor", _health_monitor - ) - scheduled_tasks.extend([task1, task2, task3, task4]) + scheduled_tasks.extend([task1, task2, task3]) diff --git a/crud.py b/crud.py index adc0836..3bec1f2 100644 --- a/crud.py +++ b/crud.py @@ -1,5 +1,4 @@ import json -from typing import List, Optional, Tuple from lnbits.helpers import urlsafe_short_hash @@ -44,7 +43,7 @@ async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant: async def update_merchant( user_id: str, merchant_id: str, config: MerchantConfig -) -> Optional[Merchant]: +) -> Merchant | None: await db.execute( f""" UPDATE nostrmarket.merchants SET meta = :meta, time = {db.timestamp_now} @@ -55,7 +54,7 @@ async def update_merchant( return await get_merchant(user_id, merchant_id) -async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: +async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None: await db.execute( f""" UPDATE nostrmarket.merchants SET time = {db.timestamp_now} @@ -66,7 +65,7 @@ async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: return await get_merchant(user_id, merchant_id) -async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: +async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None: row: dict = await db.fetchone( """SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id AND id = :id""", { @@ -78,7 +77,7 @@ async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: return Merchant.from_row(row) if row else None -async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]: +async def get_merchant_by_pubkey(public_key: str) -> Merchant | None: row: dict = await db.fetchone( """SELECT * FROM nostrmarket.merchants WHERE public_key = :public_key""", {"public_key": public_key}, @@ -87,7 +86,7 @@ async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]: return Merchant.from_row(row) if row else None -async def get_merchants_ids_with_pubkeys() -> List[Tuple[str, str]]: +async def get_merchants_ids_with_pubkeys() -> list[tuple[str, str]]: rows: list[dict] = await db.fetchall( """SELECT id, public_key FROM nostrmarket.merchants""", ) @@ -95,7 +94,7 @@ async def get_merchants_ids_with_pubkeys() -> List[Tuple[str, str]]: return [(row["id"], row["public_key"]) for row in rows] -async def get_merchant_for_user(user_id: str) -> Optional[Merchant]: +async def get_merchant_for_user(user_id: str) -> Merchant | None: row: dict = await db.fetchone( """SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id """, {"user_id": user_id}, @@ -138,7 +137,7 @@ async def create_zone(merchant_id: str, data: Zone) -> Zone: return zone -async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]: +async def update_zone(merchant_id: str, z: Zone) -> Zone | None: await db.execute( """ UPDATE nostrmarket.zones @@ -157,7 +156,7 @@ async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]: return await get_zone(merchant_id, z.id) -async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]: +async def get_zone(merchant_id: str, zone_id: str) -> Zone | None: row: dict = await db.fetchone( "SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id AND id = :id", { @@ -168,7 +167,7 @@ async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]: return Zone.from_row(row) if row else None -async def get_zones(merchant_id: str) -> List[Zone]: +async def get_zones(merchant_id: str) -> list[Zone]: rows: list[dict] = await db.fetchall( "SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id", {"merchant_id": merchant_id}, @@ -235,7 +234,7 @@ async def create_stall(merchant_id: str, data: Stall) -> Stall: return stall -async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]: +async def get_stall(merchant_id: str, stall_id: str) -> Stall | None: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.stalls @@ -249,7 +248,7 @@ async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]: return Stall.from_row(row) if row else None -async def get_stalls(merchant_id: str, pending: Optional[bool] = False) -> List[Stall]: +async def get_stalls(merchant_id: str, pending: bool | None = False) -> list[Stall]: rows: list[dict] = await db.fetchall( """ SELECT * FROM nostrmarket.stalls @@ -274,7 +273,7 @@ async def get_last_stall_update_time() -> int: return row["event_created_at"] or 0 if row else 0 -async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]: +async def update_stall(merchant_id: str, stall: Stall) -> Stall | None: await db.execute( """ UPDATE nostrmarket.stalls @@ -398,9 +397,7 @@ async def update_product(merchant_id: str, product: Product) -> Product: return updated_product -async def update_product_quantity( - product_id: str, new_quantity: int -) -> Optional[Product]: +async def update_product_quantity(product_id: str, new_quantity: int) -> Product | None: await db.execute( """ UPDATE nostrmarket.products SET quantity = :quantity @@ -415,7 +412,7 @@ async def update_product_quantity( return Product.from_row(row) if row else None -async def get_product(merchant_id: str, product_id: str) -> Optional[Product]: +async def get_product(merchant_id: str, product_id: str) -> Product | None: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.products @@ -431,8 +428,8 @@ async def get_product(merchant_id: str, product_id: str) -> Optional[Product]: async def get_products( - merchant_id: str, stall_id: str, pending: Optional[bool] = False -) -> List[Product]: + merchant_id: str, stall_id: str, pending: bool | None = False +) -> list[Product]: rows: list[dict] = await db.fetchall( """ SELECT * FROM nostrmarket.products @@ -445,8 +442,8 @@ async def get_products( async def get_products_by_ids( - merchant_id: str, product_ids: List[str] -) -> List[Product]: + merchant_id: str, product_ids: list[str] +) -> list[Product]: # todo: revisit keys = [] @@ -467,7 +464,7 @@ async def get_products_by_ids( return [Product.from_row(row) for row in rows] -async def get_wallet_for_product(product_id: str) -> Optional[str]: +async def get_wallet_for_product(product_id: str) -> str | None: row: dict = await db.fetchone( """ SELECT s.wallet as wallet FROM nostrmarket.products p @@ -574,7 +571,7 @@ async def create_order(merchant_id: str, o: Order) -> Order: return order -async def get_order(merchant_id: str, order_id: str) -> Optional[Order]: +async def get_order(merchant_id: str, order_id: str) -> Order | None: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.orders @@ -588,7 +585,7 @@ async def get_order(merchant_id: str, order_id: str) -> Optional[Order]: return Order.from_row(row) if row else None -async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Order]: +async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.orders @@ -602,7 +599,7 @@ async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Ord return Order.from_row(row) if row else None -async def get_orders(merchant_id: str, **kwargs) -> List[Order]: +async def get_orders(merchant_id: str, **kwargs) -> list[Order]: q = " AND ".join( [ f"{field[0]} = :{field[0]}" @@ -629,7 +626,7 @@ async def get_orders(merchant_id: str, **kwargs) -> List[Order]: async def get_orders_for_stall( merchant_id: str, stall_id: str, **kwargs -) -> List[Order]: +) -> list[Order]: q = " AND ".join( [ f"{field[0]} = :{field[0]}" @@ -655,7 +652,7 @@ async def get_orders_for_stall( return [Order.from_row(row) for row in rows] -async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Order]: +async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | None: q = ", ".join( [ f"{field[0]} = :{field[0]}" @@ -679,7 +676,7 @@ async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Or return await get_order(merchant_id, order_id) -async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]: +async def update_order_paid_status(order_id: str, paid: bool) -> Order | None: await db.execute( "UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id", {"paid": paid, "id": order_id}, @@ -693,7 +690,7 @@ async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order] async def update_order_shipped_status( merchant_id: str, order_id: str, shipped: bool -) -> Optional[Order]: +) -> Order | None: await db.execute( """ UPDATE nostrmarket.orders @@ -757,7 +754,7 @@ async def create_direct_message( return msg -async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMessage]: +async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | None: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.direct_messages @@ -773,7 +770,7 @@ async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMes async def get_direct_message_by_event_id( merchant_id: str, event_id: str -) -> Optional[DirectMessage]: +) -> DirectMessage | None: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.direct_messages @@ -787,7 +784,7 @@ async def get_direct_message_by_event_id( return DirectMessage.from_row(row) if row else None -async def get_direct_messages(merchant_id: str, public_key: str) -> List[DirectMessage]: +async def get_direct_messages(merchant_id: str, public_key: str) -> list[DirectMessage]: rows: list[dict] = await db.fetchall( """ SELECT * FROM nostrmarket.direct_messages @@ -799,7 +796,7 @@ async def get_direct_messages(merchant_id: str, public_key: str) -> List[DirectM return [DirectMessage.from_row(row) for row in rows] -async def get_orders_from_direct_messages(merchant_id: str) -> List[DirectMessage]: +async def get_orders_from_direct_messages(merchant_id: str) -> list[DirectMessage]: rows: list[dict] = await db.fetchall( """ SELECT * FROM nostrmarket.direct_messages @@ -860,7 +857,7 @@ async def create_customer(merchant_id: str, data: Customer) -> Customer: return customer -async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]: +async def get_customer(merchant_id: str, public_key: str) -> Customer | None: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.customers @@ -874,7 +871,7 @@ async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]: return Customer.from_row(row) if row else None -async def get_customers(merchant_id: str) -> List[Customer]: +async def get_customers(merchant_id: str) -> list[Customer]: rows: list[dict] = await db.fetchall( "SELECT * FROM nostrmarket.customers WHERE merchant_id = :merchant_id", {"merchant_id": merchant_id}, @@ -882,7 +879,7 @@ async def get_customers(merchant_id: str) -> List[Customer]: return [Customer.from_row(row) for row in rows] -async def get_all_unique_customers() -> List[Customer]: +async def get_all_unique_customers() -> list[Customer]: q = """ SELECT public_key, MAX(merchant_id) as merchant_id, MAX(event_created_at) FROM nostrmarket.customers diff --git a/description.md b/description.md index dfa898f..6446ca7 100644 --- a/description.md +++ b/description.md @@ -9,4 +9,4 @@ The Nostr Market extension includes: - A merchant client to manage products, sales and communication with customers. - A customer client to find and order products from merchants, communicate with merchants and track status of ordered products. -All communication happens over NIP-17 private direct messages (NIP-44 encryption + NIP-59 gift wrapping). +All communication happens over NIP04 encrypted DMs. diff --git a/helpers.py b/helpers.py index 35f0d0f..dcc0f06 100644 --- a/helpers.py +++ b/helpers.py @@ -1,5 +1,54 @@ +import base64 +import secrets + import coincurve from bech32 import bech32_decode, convertbits +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + +def get_shared_secret(privkey: str, pubkey: str): + pk = coincurve.PublicKey(bytes.fromhex("02" + pubkey)) + sk = coincurve.PrivateKey(bytes.fromhex(privkey)) + shared_point = pk.multiply(sk.secret) + + shared_point_bytes = shared_point.format(compressed=False) + x_coord = shared_point_bytes[1:33] + return x_coord + + +def decrypt_message(encoded_message: str, encryption_key) -> str: + encoded_data = encoded_message.split("?iv=") + if len(encoded_data) == 1: + return encoded_data[0] + encoded_content, encoded_iv = encoded_data[0], encoded_data[1] + + iv = base64.b64decode(encoded_iv) + cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv)) + encrypted_content = base64.b64decode(encoded_content) + + decryptor = cipher.decryptor() + decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize() + + unpadder = padding.PKCS7(128).unpadder() + unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize() + + return unpadded_data.decode() + + +def encrypt_message(message: str, encryption_key, iv: bytes | None = None) -> str: + padder = padding.PKCS7(128).padder() + padded_data = padder.update(message.encode()) + padder.finalize() + + iv = iv if iv else secrets.token_bytes(16) + cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv)) + + encryptor = cipher.encryptor() + encrypted_message = encryptor.update(padded_data) + encryptor.finalize() + + base64_message = base64.b64encode(encrypted_message).decode() + base64_iv = base64.b64encode(iv).decode() + return f"{base64_message}?iv={base64_iv}" def sign_message_hash(private_key: str, hash_: bytes) -> str: @@ -8,6 +57,17 @@ def sign_message_hash(private_key: str, hash_: bytes) -> str: return sig.hex() +def test_decrypt_encrypt(encoded_message: str, encryption_key): + msg = decrypt_message(encoded_message, encryption_key) + + # ecrypt using the same initialisation vector + iv = base64.b64decode(encoded_message.split("?iv=")[1]) + ecrypted_msg = encrypt_message(msg, encryption_key, iv) + assert ( + encoded_message == ecrypted_msg + ), f"expected '{encoded_message}', but got '{ecrypted_msg}'" + + def normalize_public_key(pubkey: str) -> str: if pubkey.startswith("npub1"): _, decoded_data = bech32_decode(pubkey) diff --git a/misc-docs/ORDER-DISCOVERY-ANALYSIS.md b/misc-docs/ORDER-DISCOVERY-ANALYSIS.md deleted file mode 100644 index de393e2..0000000 --- a/misc-docs/ORDER-DISCOVERY-ANALYSIS.md +++ /dev/null @@ -1,320 +0,0 @@ -# Nostrmarket Order Discovery Analysis - -## Executive Summary - -This document analyzes the order discovery mechanism in the Nostrmarket extension and identifies why merchants must manually refresh to see new orders instead of receiving them automatically through persistent subscriptions. - ---- - -## Current Architecture - -### Two Subscription Systems - -The Nostrmarket extension implements two distinct subscription mechanisms for receiving Nostr events: - -#### 1. **Persistent Subscriptions (Background Task)** - -**Purpose**: Continuous monitoring for new orders, products, and merchant events - -**Implementation**: - -- Runs via `wait_for_nostr_events()` background task -- Initiated on extension startup (15-second delay) -- Creates subscription ID: `nostrmarket-{hash}` -- Monitors all merchant public keys continuously - -**Code Location**: `/nostrmarket/tasks.py:37-49` - -```python -async def wait_for_nostr_events(nostr_client: NostrClient): - while True: - try: - await subscribe_to_all_merchants() - while True: - message = await nostr_client.get_event() - await process_nostr_message(message) -``` - -**Subscription Filters**: - -- Direct messages (kind 4) - for orders -- Stall events (kind 30017) -- Product events (kind 30018) -- Profile updates (kind 0) - -#### 2. **Temporary Subscriptions (Manual Refresh)** - -**Purpose**: Catch up on missed events when merchant clicks "Refresh from Nostr" - -**Implementation**: - -- Duration: 10 seconds only -- Triggered by user action -- Creates subscription ID: `merchant-{hash}` -- Fetches ALL events from time=0 - -**Code Location**: `/nostrmarket/nostr/nostr_client.py:100-120` - -```python -async def merchant_temp_subscription(self, pk, duration=10): - dm_filters = self._filters_for_direct_messages([pk], 0) - # ... creates filters with time=0 (all history) - await self.send_req_queue.put(["REQ", subscription_id] + merchant_filters) - asyncio.create_task(unsubscribe_with_delay(subscription_id, duration)) -``` - ---- - -## Problem Identification - -### Why Manual Refresh is Required - -#### **Issue 1: Timing Window Problem** - -The persistent subscription uses timestamps from the last database update: - -```python -async def subscribe_to_all_merchants(): - last_dm_time = await get_last_direct_messages_created_at() - last_stall_time = await get_last_stall_update_time() - last_prod_time = await get_last_product_update_time() - - await nostr_client.subscribe_merchants( - public_keys, last_dm_time, last_stall_time, last_prod_time, 0 - ) -``` - -**Problem**: Events that occur between: - -- The last database update time -- When the subscription becomes active - ...are potentially missed - -#### **Issue 2: Connection Stability** - -The WebSocket connection between components may be unstable: - -``` -[Nostrmarket] <--WebSocket--> [Nostrclient] <--WebSocket--> [Nostr Relays] - Extension Extension (Global) -``` - -**Potential failure points**: - -1. Connection drops between nostrmarket → nostrclient -2. Connection drops between nostrclient → relays -3. Reconnection doesn't re-establish subscriptions - -#### **Issue 3: Subscription State Management** - -**Current behavior**: - -- Single persistent subscription per merchant -- No automatic resubscription on failure -- No heartbeat/keepalive mechanism -- No verification that subscription is active - -#### **Issue 4: Event Processing Delays** - -The startup sequence has intentional delays: - -```python -async def _subscribe_to_nostr_client(): - await asyncio.sleep(10) # Wait for nostrclient - await nostr_client.run_forever() - -async def _wait_for_nostr_events(): - await asyncio.sleep(15) # Wait for extension init - await wait_for_nostr_events(nostr_client) -``` - -**Problem**: Orders arriving during initialization are missed - ---- - -## Why Manual Refresh Works - -The temporary subscription succeeds because: - -1. **Fetches from time=0**: Gets ALL historical events -2. **Fresh connection**: Creates new subscription request -3. **Immediate processing**: No startup delays -4. **Direct feedback**: User sees results immediately - -```python -# Temporary subscription uses time=0 (all events) -dm_filters = self._filters_for_direct_messages([pk], 0) # ← 0 means all time - -# Persistent subscription uses last update time -dm_filters = self._filters_for_direct_messages(public_keys, dm_time) # ← can miss events -``` - ---- - -## Impact Analysis - -### User Experience Issues - -1. **Merchants miss orders** without manual refresh -2. **No real-time notifications** for new orders -3. **Uncertainty** about order status -4. **Extra manual steps** required -5. **Delayed order fulfillment** - -### Technical Implications - -1. **Not truly decentralized** - requires active monitoring -2. **Scalability concerns** - manual refresh doesn't scale -3. **Reliability issues** - depends on user action -4. **Performance overhead** - fetching all events repeatedly - ---- - -## Recommended Solutions - -### Solution A: Enhanced Persistent Subscriptions - -**Implement redundant subscription mechanisms:** - -```python -class EnhancedSubscriptionManager: - def __init__(self): - self.last_heartbeat = time.time() - self.subscription_active = False - - async def maintain_subscription(self): - while True: - if not self.subscription_active or \ - time.time() - self.last_heartbeat > 30: - await self.resubscribe_with_overlap() - await asyncio.sleep(10) - - async def resubscribe_with_overlap(self): - # Use timestamp with 5-minute overlap - overlap_time = int(time.time()) - 300 - await subscribe_to_all_merchants(since=overlap_time) -``` - -### Solution B: Periodic Auto-Refresh - -**Add automatic temporary subscriptions:** - -```python -async def auto_refresh_loop(): - while True: - await asyncio.sleep(60) # Every minute - merchants = await get_all_active_merchants() - for merchant in merchants: - await merchant_temp_subscription(merchant.pubkey, duration=5) -``` - -### Solution C: WebSocket Health Monitoring - -**Implement connection health checks:** - -```python -class WebSocketHealthMonitor: - async def check_connection_health(self): - try: - # Send ping to nostrclient - response = await nostr_client.ping(timeout=5) - if not response: - await self.reconnect_and_resubscribe() - except Exception: - await self.reconnect_and_resubscribe() -``` - -### Solution D: Event Gap Detection - -**Detect and fill gaps in event sequence:** - -```python -async def detect_event_gaps(): - # Check for gaps in event timestamps - last_known = await get_last_event_time() - current_time = int(time.time()) - - if current_time - last_known > 60: # 1 minute gap - # Perform temporary subscription to fill gap - await fetch_missing_events(since=last_known) -``` - ---- - -## Implementation Priority - -### Phase 1: Quick Fixes (1-2 days) - -1. [DONE] Increase temp subscription duration (10s → 30s) -2. [DONE] Add connection health logging -3. [DONE] Reduce startup delays - -### Phase 2: Reliability (3-5 days) - -1. [TODO] Implement subscription heartbeat -2. [TODO] Add automatic resubscription on failure -3. [TODO] Create event gap detection - -### Phase 3: Full Solution (1-2 weeks) - -1. [TODO] WebSocket connection monitoring -2. [TODO] Redundant subscription system -3. [TODO] Real-time order notifications -4. [TODO] Event deduplication logic - ---- - -## Testing Recommendations - -### Test Scenarios - -1. **Order during startup**: Send order within 15 seconds of server start -2. **Long-running test**: Keep server running for 24 hours, send periodic orders -3. **Connection interruption**: Disconnect nostrclient, send order, reconnect -4. **High volume**: Send 100 orders rapidly -5. **Network latency**: Add artificial delay between components - -### Monitoring Metrics - -- Time between order sent → order discovered -- Percentage of orders requiring manual refresh -- WebSocket connection uptime -- Subscription success rate -- Event processing latency - ---- - -## Conclusion - -The current order discovery system relies on manual refresh due to: - -1. **Timing gaps** in persistent subscriptions -2. **Connection stability** issues -3. **Lack of redundancy** in subscription management -4. **No automatic recovery** mechanisms - -While the temporary subscription (manual refresh) provides a workaround, a proper solution requires implementing connection monitoring, subscription health checks, and automatic gap-filling mechanisms to ensure reliable real-time order discovery. - ---- - -## Appendix: Code References - -### Key Files - -- `/nostrmarket/tasks.py` - Background task management -- `/nostrmarket/nostr/nostr_client.py` - Nostr client implementation -- `/nostrmarket/services.py` - Order processing logic -- `/nostrmarket/views_api.py` - API endpoints for refresh - -### Relevant Functions - -- `wait_for_nostr_events()` - Main event loop -- `subscribe_to_all_merchants()` - Persistent subscription -- `merchant_temp_subscription()` - Manual refresh -- `process_nostr_message()` - Event processing - ---- - -_Document prepared: January 2025_ -_Analysis based on: Nostrmarket v1.0_ -_Status: Active Investigation_ diff --git a/models.py b/models.py index 2c24dee..58842d5 100644 --- a/models.py +++ b/models.py @@ -2,12 +2,17 @@ import json import time from abc import abstractmethod from enum import Enum -from typing import Any, List, Optional, Tuple +from typing import Any from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis from pydantic import BaseModel -from .helpers import sign_message_hash +from .helpers import ( + decrypt_message, + encrypt_message, + get_shared_secret, + sign_message_hash, +) from .nostr.event import NostrEvent ######################################## NOSTR ######################################## @@ -27,26 +32,21 @@ class Nostrable: class MerchantProfile(BaseModel): - name: Optional[str] = None - display_name: Optional[str] = None - about: Optional[str] = None - picture: Optional[str] = None - banner: Optional[str] = None - website: Optional[str] = None - nip05: Optional[str] = None - lud16: Optional[str] = None + name: str | None = None + display_name: str | None = None + about: str | None = None + picture: str | None = None + banner: str | None = None + website: str | None = None + nip05: str | None = None + lud16: str | None = None class MerchantConfig(MerchantProfile): - event_id: Optional[str] = None + event_id: str | None = None sync_from_nostr: bool = False - # TODO: switched to True for AIO demo; determine if we leave this as True - active: bool = True - restore_in_progress: Optional[bool] = False - - -class CreateMerchantRequest(BaseModel): - config: MerchantConfig = MerchantConfig() + active: bool = False + restore_in_progress: bool | None = False class PartialMerchant(BaseModel): @@ -57,11 +57,33 @@ class PartialMerchant(BaseModel): class Merchant(PartialMerchant, Nostrable): id: str - time: Optional[int] = 0 + time: int | None = 0 def sign_hash(self, hash_: bytes) -> str: return sign_message_hash(self.private_key, hash_) + def decrypt_message(self, encrypted_message: str, public_key: str) -> str: + encryption_key = get_shared_secret(self.private_key, public_key) + return decrypt_message(encrypted_message, encryption_key) + + def encrypt_message(self, clear_text_message: str, public_key: str) -> str: + encryption_key = get_shared_secret(self.private_key, public_key) + return encrypt_message(clear_text_message, encryption_key) + + def build_dm_event(self, message: str, to_pubkey: str) -> NostrEvent: + content = self.encrypt_message(message, to_pubkey) + event = NostrEvent( + pubkey=self.public_key, + created_at=round(time.time()), + kind=4, + tags=[["p", to_pubkey]], + content=content, + ) + event.id = event.event_id + event.sig = self.sign_hash(bytes.fromhex(event.id)) + + return event + @classmethod def from_row(cls, row: dict) -> "Merchant": merchant = cls(**row) @@ -117,11 +139,11 @@ class Merchant(PartialMerchant, Nostrable): ######################################## ZONES ######################################## class Zone(BaseModel): - id: Optional[str] = None - name: Optional[str] = None + id: str | None = None + name: str | None = None currency: str cost: float - countries: List[str] = [] + countries: list[str] = [] @classmethod def from_row(cls, row: dict) -> "Zone": @@ -134,22 +156,22 @@ class Zone(BaseModel): class StallConfig(BaseModel): - image_url: Optional[str] = None - description: Optional[str] = None + image_url: str | None = None + description: str | None = None class Stall(BaseModel, Nostrable): - id: Optional[str] = None + id: str | None = None wallet: str name: str currency: str = "sat" - shipping_zones: List[Zone] = [] + shipping_zones: list[Zone] = [] config: StallConfig = StallConfig() pending: bool = False """Last published nostr event for this Stall""" - event_id: Optional[str] = None - event_created_at: Optional[int] = None + event_id: str | None = None + event_created_at: int | None = None def validate_stall(self): for z in self.shipping_zones: @@ -207,19 +229,19 @@ class ProductShippingCost(BaseModel): class ProductConfig(BaseModel): - description: Optional[str] = None - currency: Optional[str] = None - use_autoreply: Optional[bool] = False - autoreply_message: Optional[str] = None - shipping: List[ProductShippingCost] = [] + description: str | None = None + currency: str | None = None + use_autoreply: bool | None = False + autoreply_message: str | None = None + shipping: list[ProductShippingCost] = [] class Product(BaseModel, Nostrable): - id: Optional[str] = None + id: str | None = None stall_id: str name: str - categories: List[str] = [] - images: List[str] = [] + categories: list[str] = [] + images: list[str] = [] price: float quantity: int active: bool = True @@ -227,8 +249,8 @@ class Product(BaseModel, Nostrable): config: ProductConfig = ProductConfig() """Last published nostr event for this Product""" - event_id: Optional[str] = None - event_created_at: Optional[int] = None + event_id: str | None = None + event_created_at: int | None = None def to_nostr_event(self, pubkey: str) -> NostrEvent: content = { @@ -285,7 +307,7 @@ class ProductOverview(BaseModel): id: str name: str price: float - product_shipping_cost: Optional[float] = None + product_shipping_cost: float | None = None @classmethod def from_product(cls, p: Product) -> "ProductOverview": @@ -302,21 +324,21 @@ class OrderItem(BaseModel): class OrderContact(BaseModel): - nostr: Optional[str] = None - phone: Optional[str] = None - email: Optional[str] = None + nostr: str | None = None + phone: str | None = None + email: str | None = None class OrderExtra(BaseModel): - products: List[ProductOverview] + products: list[ProductOverview] currency: str btc_price: str shipping_cost: float = 0 shipping_cost_sat: float = 0 - fail_message: Optional[str] = None + fail_message: str | None = None @classmethod - async def from_products(cls, products: List[Product]): + async def from_products(cls, products: list[Product]): currency = products[0].config.currency if len(products) else "sat" exchange_rate = ( await btc_price(currency) if currency and currency != "sat" else 1 @@ -332,19 +354,19 @@ class OrderExtra(BaseModel): class PartialOrder(BaseModel): id: str - event_id: Optional[str] = None - event_created_at: Optional[int] = None + event_id: str | None = None + event_created_at: int | None = None public_key: str merchant_public_key: str shipping_id: str - items: List[OrderItem] - contact: Optional[OrderContact] = None - address: Optional[str] = None + items: list[OrderItem] + contact: OrderContact | None = None + address: str | None = None def validate_order(self): assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'" - def validate_order_items(self, product_list: List[Product]): + def validate_order_items(self, product_list: list[Product]): assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'" assert ( len(product_list) != 0 @@ -365,8 +387,8 @@ class PartialOrder(BaseModel): ) async def costs_in_sats( - self, products: List[Product], shipping_id: str, stall_shipping_cost: float - ) -> Tuple[float, float]: + self, products: list[Product], shipping_id: str, stall_shipping_cost: float + ) -> tuple[float, float]: product_prices = {} for p in products: product_shipping_cost = next( @@ -395,7 +417,7 @@ class PartialOrder(BaseModel): return product_cost, stall_shipping_cost def receipt( - self, products: List[Product], shipping_id: str, stall_shipping_cost: float + self, products: list[Product], shipping_id: str, stall_shipping_cost: float ) -> str: if len(products) == 0: return "[No Products]" @@ -444,7 +466,7 @@ class Order(PartialOrder): total: float paid: bool = False shipped: bool = False - time: Optional[int] = None + time: int | None = None extra: OrderExtra @classmethod @@ -458,14 +480,14 @@ class Order(PartialOrder): class OrderStatusUpdate(BaseModel): id: str - message: Optional[str] = None - paid: Optional[bool] = False - shipped: Optional[bool] = None + message: str | None = None + paid: bool | None = False + shipped: bool | None = None class OrderReissue(BaseModel): id: str - shipping_id: Optional[str] = None + shipping_id: str | None = None class PaymentOption(BaseModel): @@ -475,8 +497,8 @@ class PaymentOption(BaseModel): class PaymentRequest(BaseModel): id: str - message: Optional[str] = None - payment_options: List[PaymentOption] + message: str | None = None + payment_options: list[PaymentOption] ######################################## MESSAGE ####################################### @@ -492,16 +514,16 @@ class DirectMessageType(Enum): class PartialDirectMessage(BaseModel): - event_id: Optional[str] = None - event_created_at: Optional[int] = None + event_id: str | None = None + event_created_at: int | None = None message: str public_key: str type: int = DirectMessageType.PLAIN_TEXT.value incoming: bool = False - time: Optional[int] = None + time: int | None = None @classmethod - def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]: + def parse_message(cls, msg) -> tuple[DirectMessageType, Any | None]: try: msg_json = json.loads(msg) if "type" in msg_json: @@ -524,15 +546,15 @@ class DirectMessage(PartialDirectMessage): class CustomerProfile(BaseModel): - name: Optional[str] = None - about: Optional[str] = None + name: str | None = None + about: str | None = None class Customer(BaseModel): merchant_id: str public_key: str - event_created_at: Optional[int] = None - profile: Optional[CustomerProfile] = None + event_created_at: int | None = None + profile: CustomerProfile | None = None unread_messages: int = 0 @classmethod diff --git a/nostr/nip44.py b/nostr/nip44.py deleted file mode 100644 index 908ad8a..0000000 --- a/nostr/nip44.py +++ /dev/null @@ -1,180 +0,0 @@ -""" -NIP-44 v2: Encrypted Payloads (Versioned) - -secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256, base64 - -Reference: https://github.com/nostr-protocol/nips/blob/master/44.md -""" - -import base64 -import hashlib -import hmac -import math -import secrets -import struct - -import coincurve -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms -from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand -from cryptography.hazmat.primitives import hashes - -VERSION = 2 -MIN_PLAINTEXT_SIZE = 1 -MAX_PLAINTEXT_SIZE = 65535 - - -def get_conversation_key(private_key_hex: str, public_key_hex: str) -> bytes: - """ - Calculate long-term conversation key between two users via ECDH + HKDF-extract. - Symmetric: get_conversation_key(a, B) == get_conversation_key(b, A) - """ - pk = coincurve.PublicKey(bytes.fromhex("02" + public_key_hex)) - sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex)) - shared_point = pk.multiply(sk.secret) - shared_x = shared_point.format(compressed=False)[1:33] - - # HKDF-extract only (not expand) with salt='nip44-v2' - conversation_key = hmac.new(b"nip44-v2", shared_x, hashlib.sha256).digest() - return conversation_key - - -def get_message_keys( - conversation_key: bytes, nonce: bytes -) -> tuple[bytes, bytes, bytes]: - """ - Derive per-message keys from conversation_key and nonce using HKDF-expand. - Returns (chacha_key, chacha_nonce, hmac_key). - """ - if len(conversation_key) != 32: - raise ValueError("invalid conversation_key length") - if len(nonce) != 32: - raise ValueError("invalid nonce length") - - keys = HKDFExpand( - algorithm=hashes.SHA256(), - length=76, - info=nonce, - ).derive(conversation_key) - - chacha_key = keys[0:32] - chacha_nonce = keys[32:44] - hmac_key = keys[44:76] - return chacha_key, chacha_nonce, hmac_key - - -def calc_padded_len(unpadded_len: int) -> int: - """Calculate padded length using power-of-two chunking.""" - if unpadded_len <= 0: - raise ValueError("invalid plaintext length") - if unpadded_len <= 32: - return 32 - next_power = 1 << (math.floor(math.log2(unpadded_len - 1)) + 1) - if next_power <= 256: - chunk = 32 - else: - chunk = next_power // 8 - return chunk * (math.floor((unpadded_len - 1) / chunk) + 1) - - -def _pad(plaintext: str) -> bytes: - """Convert plaintext string to padded byte array.""" - unpadded = plaintext.encode("utf-8") - unpadded_len = len(unpadded) - if unpadded_len < MIN_PLAINTEXT_SIZE or unpadded_len > MAX_PLAINTEXT_SIZE: - raise ValueError( - f"invalid plaintext length: {unpadded_len} " - f"(must be {MIN_PLAINTEXT_SIZE}..{MAX_PLAINTEXT_SIZE})" - ) - prefix = struct.pack(">H", unpadded_len) - padded_len = calc_padded_len(unpadded_len) - suffix = b"\x00" * (padded_len - unpadded_len) - return prefix + unpadded + suffix - - -def _unpad(padded: bytes) -> str: - """Convert padded byte array back to plaintext string.""" - unpadded_len = struct.unpack(">H", padded[0:2])[0] - unpadded = padded[2 : 2 + unpadded_len] - if ( - unpadded_len == 0 - or len(unpadded) != unpadded_len - or len(padded) != 2 + calc_padded_len(unpadded_len) - ): - raise ValueError("invalid padding") - return unpadded.decode("utf-8") - - -def _hmac_aad(key: bytes, message: bytes, aad: bytes) -> bytes: - """HMAC-SHA256 with AAD: hmac(key, aad || message).""" - if len(aad) != 32: - raise ValueError("AAD associated data must be 32 bytes") - return hmac.new(key, aad + message, hashlib.sha256).digest() - - -def _chacha20(key: bytes, nonce: bytes, data: bytes) -> bytes: - """ChaCha20 encrypt/decrypt with initial counter = 0.""" - # cryptography's ChaCha20 takes a 16-byte nonce: 4-byte counter (LE) + 12-byte nonce - full_nonce = b"\x00\x00\x00\x00" + nonce - cipher = Cipher(algorithms.ChaCha20(key, full_nonce), mode=None) - encryptor = cipher.encryptor() - return encryptor.update(data) + encryptor.finalize() - - -def _decode_payload(payload: str) -> tuple[bytes, bytes, bytes]: - """Decode base64 payload into (nonce, ciphertext, mac).""" - plen = len(payload) - if plen == 0 or payload[0] == "#": - raise ValueError("unknown version") - if plen < 132 or plen > 87472: - raise ValueError("invalid payload size") - - data = base64.b64decode(payload) - dlen = len(data) - if dlen < 99 or dlen > 65603: - raise ValueError("invalid data size") - - vers = data[0] - if vers != VERSION: - raise ValueError(f"unknown version {vers}") - - nonce = data[1:33] - ciphertext = data[33 : dlen - 32] - mac = data[dlen - 32 : dlen] - return nonce, ciphertext, mac - - -def encrypt( - plaintext: str, - conversation_key: bytes, - nonce: bytes | None = None, -) -> str: - """ - Encrypt plaintext using NIP-44 v2. - Returns base64-encoded payload. - """ - if nonce is None: - nonce = secrets.token_bytes(32) - if len(nonce) != 32: - raise ValueError("invalid nonce length") - - chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce) - padded = _pad(plaintext) - ciphertext = _chacha20(chacha_key, chacha_nonce, padded) - mac = _hmac_aad(hmac_key, ciphertext, nonce) - return base64.b64encode( - struct.pack("B", VERSION) + nonce + ciphertext + mac - ).decode("ascii") - - -def decrypt(payload: str, conversation_key: bytes) -> str: - """ - Decrypt a NIP-44 v2 base64 payload. - Returns plaintext string. - """ - nonce, ciphertext, mac = _decode_payload(payload) - chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce) - calculated_mac = _hmac_aad(hmac_key, ciphertext, nonce) - if not hmac.compare_digest(calculated_mac, mac): - raise ValueError("invalid MAC") - padded_plaintext = _chacha20(chacha_key, chacha_nonce, ciphertext) - return _unpad(padded_plaintext) diff --git a/nostr/nip59.py b/nostr/nip59.py deleted file mode 100644 index 2283bee..0000000 --- a/nostr/nip59.py +++ /dev/null @@ -1,178 +0,0 @@ -""" -NIP-59: Gift Wrap - -Three-layer protocol for metadata-protected messaging: - 1. Rumor (unsigned event) — carries content, deniable if leaked - 2. Seal (kind 13) — encrypts rumor, signed by author, no recipient metadata - 3. Gift Wrap (kind 1059) — encrypts seal with ephemeral key, has recipient p-tag - -Reference: https://github.com/nostr-protocol/nips/blob/master/59.md -""" - -import json -import secrets -import time -from typing import Optional - -import coincurve - -from .event import NostrEvent -from .nip44 import decrypt as nip44_decrypt -from .nip44 import encrypt as nip44_encrypt -from .nip44 import get_conversation_key - -TWO_DAYS = 2 * 24 * 60 * 60 - - -def _random_past_timestamp() -> int: - """Generate a timestamp randomly in the past 0-2 days for metadata protection.""" - return int(time.time()) - secrets.randbelow(TWO_DAYS) - - -def _sign_event(event: NostrEvent, private_key_hex: str) -> NostrEvent: - """Compute event id and sign it.""" - event.id = event.event_id - sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex)) - event.sig = sk.sign_schnorr(bytes.fromhex(event.id)).hex() - return event - - -def _pubkey_from_privkey(private_key_hex: str) -> str: - """Derive x-only public key hex from private key hex.""" - sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex)) - return sk.public_key.format(compressed=True)[1:].hex() - - -def create_rumor( - pubkey: str, - content: str, - kind: int = 14, - tags: Optional[list[list[str]]] = None, - created_at: Optional[int] = None, -) -> NostrEvent: - """ - Create an unsigned rumor event. - The event has an id but no signature, making it deniable. - """ - event = NostrEvent( - pubkey=pubkey, - created_at=created_at or int(time.time()), - kind=kind, - tags=tags or [], - content=content, - ) - event.id = event.event_id - # sig intentionally left as None (unsigned) - return event - - -def create_seal( - rumor: NostrEvent, - sender_privkey: str, - recipient_pubkey: str, -) -> NostrEvent: - """ - Create a kind 13 seal: encrypts the rumor for the recipient. - Signed by the sender. Tags are always empty. - """ - conv_key = get_conversation_key(sender_privkey, recipient_pubkey) - encrypted_rumor = nip44_encrypt(rumor.stringify(), conv_key) - - seal = NostrEvent( - pubkey=_pubkey_from_privkey(sender_privkey), - created_at=_random_past_timestamp(), - kind=13, - tags=[], - content=encrypted_rumor, - ) - return _sign_event(seal, sender_privkey) - - -def create_gift_wrap( - seal: NostrEvent, - recipient_pubkey: str, -) -> NostrEvent: - """ - Create a kind 1059 gift wrap: encrypts the seal with an ephemeral key. - The only public metadata is the recipient's p-tag. - """ - ephemeral_privkey = secrets.token_bytes(32).hex() - ephemeral_pubkey = _pubkey_from_privkey(ephemeral_privkey) - - conv_key = get_conversation_key(ephemeral_privkey, recipient_pubkey) - encrypted_seal = nip44_encrypt(seal.stringify(), conv_key) - - wrap = NostrEvent( - pubkey=ephemeral_pubkey, - created_at=_random_past_timestamp(), - kind=1059, - tags=[["p", recipient_pubkey]], - content=encrypted_seal, - ) - return _sign_event(wrap, ephemeral_privkey) - - -def unwrap_gift_wrap( - gift_wrap: NostrEvent, - recipient_privkey: str, -) -> NostrEvent: - """ - Decrypt a kind 1059 gift wrap to reveal the inner seal. - Uses the recipient's private key and the gift wrap's ephemeral pubkey. - """ - conv_key = get_conversation_key(recipient_privkey, gift_wrap.pubkey) - seal_json = nip44_decrypt(gift_wrap.content, conv_key) - return NostrEvent(**json.loads(seal_json)) - - -def unseal( - seal: NostrEvent, - recipient_privkey: str, -) -> NostrEvent: - """ - Decrypt a kind 13 seal to reveal the inner rumor. - Uses the recipient's private key and the seal's pubkey (the sender). - Validates that the rumor's pubkey matches the seal's pubkey. - """ - conv_key = get_conversation_key(recipient_privkey, seal.pubkey) - rumor_json = nip44_decrypt(seal.content, conv_key) - rumor = NostrEvent(**json.loads(rumor_json)) - - if rumor.pubkey != seal.pubkey: - raise ValueError( - f"rumor pubkey ({rumor.pubkey}) does not match " - f"seal pubkey ({seal.pubkey})" - ) - return rumor - - -# --- Convenience functions --- - - -def wrap_message( - content: str, - sender_privkey: str, - sender_pubkey: str, - recipient_pubkey: str, - kind: int = 14, - tags: Optional[list[list[str]]] = None, -) -> NostrEvent: - """ - Full wrap pipeline: create rumor -> seal -> gift wrap. - Returns the gift wrap event ready to publish. - """ - rumor = create_rumor(sender_pubkey, content, kind=kind, tags=tags) - seal = create_seal(rumor, sender_privkey, recipient_pubkey) - return create_gift_wrap(seal, recipient_pubkey) - - -def unwrap_message( - gift_wrap: NostrEvent, - recipient_privkey: str, -) -> NostrEvent: - """ - Full unwrap pipeline: gift wrap -> seal -> rumor. - Returns the rumor with sender pubkey and plaintext content. - """ - seal = unwrap_gift_wrap(gift_wrap, recipient_privkey) - return unseal(seal, recipient_privkey) diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index c51d19d..a611980 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -1,8 +1,6 @@ import asyncio import json -import time from asyncio import Queue -from collections import OrderedDict from threading import Thread from typing import Callable, List, Optional @@ -14,8 +12,6 @@ from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash from .event import NostrEvent -MAX_SEEN_EVENTS = 1000 - class NostrClient: def __init__(self): @@ -24,8 +20,6 @@ class NostrClient: self.ws: Optional[WebSocketApp] = None self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32] self.running = False - self._seen_events: OrderedDict[str, None] = OrderedDict() - self.last_event_at: float = 0 @property def is_websocket_connected(self): @@ -37,11 +31,9 @@ class NostrClient: logger.debug(f"Connecting to websockets for 'nostrclient' extension...") relay_endpoint = encrypt_internal_message("relay", urlsafe=True) - ws_url = f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}" - on_open, on_message, on_error, on_close = self._ws_handlers() ws = WebSocketApp( - ws_url, + f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}", on_message=on_message, on_open=on_open, on_close=on_close, @@ -70,21 +62,10 @@ class NostrClient: logger.warning(ex) await asyncio.sleep(60) - def is_duplicate_event(self, event_id: str) -> bool: - """Check if an event has been seen recently. Returns True if duplicate.""" - if event_id in self._seen_events: - return True - self._seen_events[event_id] = None - if len(self._seen_events) > MAX_SEEN_EVENTS: - self._seen_events.popitem(last=False) - return False - async def get_event(self): value = await self.recieve_event_queue.get() if isinstance(value, ValueError): - logger.error(f"[NOSTRMARKET] ❌ Queue returned error: {value}") raise value - self.last_event_at = time.time() return value async def publish_nostr_event(self, e: NostrEvent): @@ -110,6 +91,10 @@ class NostrClient: self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32] await self.send_req_queue.put(["REQ", self.subscription_id] + merchant_filters) + logger.debug( + f"Subscribing to events for: {len(public_keys)} keys. New subscription id: {self.subscription_id}" + ) + async def merchant_temp_subscription(self, pk, duration=10): dm_filters = self._filters_for_direct_messages([pk], 0) stall_filters = self._filters_for_stall_events([pk], 0) @@ -150,13 +135,13 @@ class NostrClient: logger.debug(ex) def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List: - # NIP-17/NIP-59: subscribe to kind 1059 gift wraps addressed to our merchants. - # With gift wrapping, outgoing messages are self-wrapped (same p-tag filter). - gift_wrap_filter: dict = {"kinds": [1059], "#p": public_keys} + in_messages_filter = {"kinds": [4], "#p": public_keys} + out_messages_filter = {"kinds": [4], "authors": public_keys} if since and since != 0: - gift_wrap_filter["since"] = since + in_messages_filter["since"] = since + out_messages_filter["since"] = since - return [gift_wrap_filter] + return [in_messages_filter, out_messages_filter] def _filters_for_stall_events(self, public_keys: List[str], since: int) -> List: stall_filter = {"kinds": [30017], "authors": public_keys} @@ -190,21 +175,16 @@ class NostrClient: def _ws_handlers(self): def on_open(_): - logger.debug("[NOSTRMARKET DEBUG] ✅ Connected to 'nostrclient' websocket successfully") + logger.info("Connected to 'nostrclient' websocket") def on_message(_, message): - logger.debug(f"[NOSTRMARKET DEBUG] 📨 Received websocket message: {message[:200]}...") - try: - self.recieve_event_queue.put_nowait(message) - logger.debug(f"[NOSTRMARKET DEBUG] 📤 Message queued successfully") - except Exception as e: - logger.error(f"[NOSTRMARKET] ❌ Failed to queue message: {e}") + self.recieve_event_queue.put_nowait(message) def on_error(_, error): - logger.warning(f"[NOSTRMARKET] ❌ Websocket error: {error}") + logger.warning(error) def on_close(x, status_code, message): - logger.warning(f"[NOSTRMARKET] 🔌 Websocket closed: {x}: '{status_code}' '{message}'") + logger.warning(f"Websocket closed: {x}: '{status_code}' '{message}'") # force re-subscribe self.recieve_event_queue.put_nowait(ValueError("Websocket close.")) diff --git a/services.py b/services.py index 2a7159e..b978e39 100644 --- a/services.py +++ b/services.py @@ -1,8 +1,7 @@ import asyncio import json -from typing import List, Optional, Tuple -from lnbits.bolt11 import decode +from bolt11 import decode from lnbits.core.crud import get_wallet from lnbits.core.services import create_invoice, websocket_updater from loguru import logger @@ -56,17 +55,15 @@ from .models import ( Stall, ) from .nostr.event import NostrEvent -from .nostr.nip59 import unwrap_message, wrap_message async def create_new_order( merchant_public_key: str, data: PartialOrder -) -> Optional[PaymentRequest]: +) -> PaymentRequest | None: merchant = await get_merchant_by_pubkey(merchant_public_key) assert merchant, "Cannot find merchant for order!" - existing_order = await get_order(merchant.id, data.id) - if existing_order: + if await get_order(merchant.id, data.id): return None if data.event_id and await get_order_by_event_id(merchant.id, data.event_id): return None @@ -76,24 +73,20 @@ async def create_new_order( ) await create_order(merchant.id, order) - payment_request = PaymentRequest( + return PaymentRequest( id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)], message=receipt, ) - return payment_request async def build_order_with_payment( merchant_id: str, merchant_public_key: str, data: PartialOrder ): - products = await get_products_by_ids( merchant_id, [p.product_id for p in data.items] ) - data.validate_order_items(products) - shipping_zone = await get_zone(merchant_id, data.shipping_id) assert shipping_zone, f"Shipping zone not found for order '{data.id}'" @@ -101,7 +94,6 @@ async def build_order_with_payment( product_cost_sat, shipping_cost_sat = await data.costs_in_sats( products, shipping_zone.id, shipping_zone.cost ) - receipt = data.receipt(products, shipping_zone.id, shipping_zone.cost) wallet_id = await get_wallet_for_product(data.items[0].product_id) @@ -112,13 +104,11 @@ async def build_order_with_payment( merchant_id, product_ids, data.items ) if not success: - logger.error(f"[NOSTRMARKET] ❌ Product quantity check failed: {message}") raise ValueError(message) - total_amount_sat = round(product_cost_sat + shipping_cost_sat) payment = await create_invoice( wallet_id=wallet_id, - amount=total_amount_sat, + amount=round(product_cost_sat + shipping_cost_sat), memo=f"Order '{data.id}' for pubkey '{data.public_key}'", extra={ "tag": "nostrmarket", @@ -146,7 +136,7 @@ async def update_merchant_to_nostr( merchant: Merchant, delete_merchant=False ) -> Merchant: stalls = await get_stalls(merchant.id) - event: Optional[NostrEvent] = None + event: NostrEvent | None = None for stall in stalls: assert stall.id products = await get_products(merchant.id, stall.id) @@ -230,7 +220,7 @@ async def notify_client_of_order_status( async def update_products_for_order( merchant: Merchant, order: Order -) -> Tuple[bool, str]: +) -> tuple[bool, str]: product_ids = [i.product_id for i in order.items] success, products, message = await compute_products_new_quantity( merchant.id, product_ids, order.items @@ -271,34 +261,19 @@ async def send_dm( other_pubkey: str, type_: int, dm_content: str, -) -> DirectMessage: - # Wrap message to recipient via NIP-59 gift wrap - gift_wrap = wrap_message( - dm_content, - merchant.private_key, - merchant.public_key, - other_pubkey, - ) +): + dm_event = merchant.build_dm_event(dm_content, other_pubkey) dm = PartialDirectMessage( - event_id=gift_wrap.id, - event_created_at=gift_wrap.created_at, + event_id=dm_event.id, + event_created_at=dm_event.created_at, message=dm_content, public_key=other_pubkey, type=type_, ) dm_reply = await create_direct_message(merchant.id, dm) - await nostr_client.publish_nostr_event(gift_wrap) - - # Also wrap a copy to self for archival - self_wrap = wrap_message( - dm_content, - merchant.private_key, - merchant.public_key, - merchant.public_key, - ) - await nostr_client.publish_nostr_event(self_wrap) + await nostr_client.publish_nostr_event(dm_event) await websocket_updater( merchant.id, @@ -311,13 +286,11 @@ async def send_dm( ), ) - return dm_reply - async def compute_products_new_quantity( - merchant_id: str, product_ids: List[str], items: List[OrderItem] -) -> Tuple[bool, List[Product], str]: - products: List[Product] = await get_products_by_ids(merchant_id, product_ids) + merchant_id: str, product_ids: list[str], items: list[OrderItem] +) -> tuple[bool, list[Product], str]: + products: list[Product] = await get_products_by_ids(merchant_id, product_ids) for p in products: required_quantity = next( @@ -340,38 +313,23 @@ async def compute_products_new_quantity( async def process_nostr_message(msg: str): try: - parsed_msg = json.loads(msg) - type_, *rest = parsed_msg - + type_, *rest = json.loads(msg) if type_.upper() == "EVENT": - if len(rest) < 2: - logger.warning(f"[NOSTRMARKET] ⚠️ EVENT message missing data: {rest}") - return _, event = rest event = NostrEvent(**event) - - # Deduplicate events (overlap resubscriptions may deliver duplicates) - if nostr_client.is_duplicate_event(event.id): - return - if event.kind == 0: await _handle_customer_profile_update(event) - elif event.kind == 1059: - await _handle_gift_wrap(event) + elif event.kind == 4: + await _handle_nip04_message(event) elif event.kind == 30017: await _handle_stall(event) elif event.kind == 30018: await _handle_product(event) - else: - logger.info(f"[NOSTRMARKET] ❓ Unhandled event kind: {event.kind} - event: {event.id}") return - else: - logger.info(f"[NOSTRMARKET] 🔄 Non-EVENT message type: {type_}") except Exception as ex: - logger.error(f"[NOSTRMARKET] ❌ Error processing nostr message: {ex}") - logger.error(f"[NOSTRMARKET] 📄 Raw message that failed: {msg}") + logger.debug(ex) async def create_or_update_order_from_dm( @@ -452,41 +410,29 @@ async def extract_customer_order_from_dm( return order -async def _handle_gift_wrap(event: NostrEvent): - """Handle an incoming kind 1059 gift wrap event (NIP-59/NIP-17).""" +async def _handle_nip04_message(event: NostrEvent): + merchant_public_key = event.pubkey + merchant = await get_merchant_by_pubkey(merchant_public_key) - p_tags = event.tag_values("p") - if not p_tags: - logger.warning(f"[NOSTRMARKET] ⚠️ Gift wrap has no p-tag: {event.id}") - return - - # The p-tag identifies the recipient of the gift wrap - recipient_pubkey = p_tags[0] - merchant = await get_merchant_by_pubkey(recipient_pubkey) if not merchant: - logger.warning( - f"[NOSTRMARKET] ⚠️ No merchant found for gift wrap recipient: {recipient_pubkey}" + p_tags = event.tag_values("p") + if len(p_tags) and p_tags[0]: + merchant_public_key = p_tags[0] + merchant = await get_merchant_by_pubkey(merchant_public_key) + + assert merchant, f"Merchant not found for public key '{merchant_public_key}'" + + if event.pubkey == merchant_public_key: + assert len(event.tag_values("p")) != 0, "Outgong message has no 'p' tag" + clear_text_msg = merchant.decrypt_message( + event.content, event.tag_values("p")[0] ) - return - - try: - rumor = unwrap_message(event, merchant.private_key) - except Exception as ex: - logger.error(f"[NOSTRMARKET] ❌ Failed to unwrap gift wrap {event.id}: {ex}") - return - - sender_pubkey = rumor.pubkey - - if sender_pubkey == merchant.public_key: - # This is a self-addressed wrap (outgoing message archive) - # Extract the actual recipient from the rumor's p-tags - rumor_p_tags = rumor.tag_values("p") - if rumor_p_tags: - await _handle_outgoing_dms(rumor, merchant, rumor.content) - return - - # Incoming message from a customer - await _handle_incoming_dms(rumor, merchant, rumor.content) + await _handle_outgoing_dms(event, merchant, clear_text_msg) + elif event.has_tag_value("p", merchant_public_key): + clear_text_msg = merchant.decrypt_message(event.content, event.pubkey) + await _handle_incoming_dms(event, merchant, clear_text_msg) + else: + logger.warning(f"Bad NIP04 event: '{event.id}'") async def _handle_incoming_dms( @@ -536,18 +482,17 @@ async def _handle_outgoing_dms( async def _handle_incoming_structured_dm( merchant: Merchant, dm: DirectMessage, json_data: dict -) -> Tuple[DirectMessageType, Optional[str]]: +) -> tuple[DirectMessageType, str | None]: try: if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active: json_resp = await _handle_new_order( merchant.id, merchant.public_key, dm, json_data ) + return DirectMessageType.PAYMENT_REQUEST, json_resp - else: - logger.info(f"[NOSTRMARKET] Skipping order processing - type: {dm.type}, expected: {DirectMessageType.CUSTOMER_ORDER.value}, merchant_active: {merchant.config.active}") except Exception as ex: - logger.error(f"[NOSTRMARKET] Error in _handle_incoming_structured_dm: {ex}") + logger.warning(ex) return DirectMessageType.PLAIN_TEXT, None @@ -586,21 +531,16 @@ async def _persist_dm( async def reply_to_structured_dm( merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str ): - gift_wrap = wrap_message( - dm_reply, - merchant.private_key, - merchant.public_key, - customer_pubkey, - ) + dm_event = merchant.build_dm_event(dm_reply, customer_pubkey) dm = PartialDirectMessage( - event_id=gift_wrap.id, - event_created_at=gift_wrap.created_at, + event_id=dm_event.id, + event_created_at=dm_event.created_at, message=dm_reply, public_key=customer_pubkey, type=dm_type, ) await create_direct_message(merchant.id, dm) - await nostr_client.publish_nostr_event(gift_wrap) + await nostr_client.publish_nostr_event(dm_event) await websocket_updater( merchant.id, @@ -633,31 +573,9 @@ async def _handle_new_order( wallet = await get_wallet(wallet_id) assert wallet, f"Cannot find wallet for product id: {first_product_id}" - payment_req = await create_new_order(merchant_public_key, partial_order) - - if payment_req is None: - # Return existing order data instead of creating a failed order - existing_order = await get_order(merchant_id, partial_order.id) - if existing_order and existing_order.invoice_id != "None": - # Order exists with invoice, return existing payment request - duplicate_response = json.dumps({ - "type": DirectMessageType.PAYMENT_REQUEST.value, - "id": existing_order.id, - "message": "Order already received and processed", - "payment_options": [] - }, separators=(",", ":"), ensure_ascii=False) - return duplicate_response - else: - # Order exists but no invoice, skip processing - logger.info(f"[NOSTRMARKET] Order exists but no invoice, returning empty string") - return "" - except Exception as e: - logger.error(f"[NOSTRMARKET] Error creating order: {e}") - logger.error(f"[NOSTRMARKET] Order data: {json_data}") - logger.error(f"[NOSTRMARKET] Exception type: {type(e).__name__}") - logger.error(f"[NOSTRMARKET] Exception details: {str(e)}") + logger.debug(e) payment_req = await create_new_failed_order( merchant_id, merchant_public_key, @@ -665,17 +583,12 @@ async def _handle_new_order( json_data, "Order received, but cannot be processed. Please contact merchant.", ) - - if not payment_req: - logger.error(f"[NOSTRMARKET] No payment request returned for order: {partial_order.id}") - return "" - + assert payment_req response = { "type": DirectMessageType.PAYMENT_REQUEST.value, **payment_req.dict(), } - response_json = json.dumps(response, separators=(",", ":"), ensure_ascii=False) - return response_json + return json.dumps(response, separators=(",", ":"), ensure_ascii=False) async def create_new_failed_order( @@ -708,11 +621,8 @@ async def subscribe_to_all_merchants(): last_stall_time = await get_last_stall_update_time() last_prod_time = await get_last_product_update_time() - # Make dm_time more lenient by subtracting 5 minutes to avoid missing recent events - lenient_dm_time = max(0, last_dm_time - 300) if last_dm_time > 0 else 0 - await nostr_client.subscribe_merchants( - public_keys, lenient_dm_time, last_stall_time, last_prod_time, 0 + public_keys, last_dm_time, last_stall_time, last_prod_time, 0 ) diff --git a/static/js/index.js b/static/js/index.js index b10220c..f14bf1d 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -49,19 +49,46 @@ window.app = Vue.createApp({ } }, methods: { - generateKeys: async function () { - // No longer need to generate keys here - the backend will use user's existing keypairs - await this.createMerchant() + generateKeys: function () { + const privateKey = nostr.generatePrivateKey() + const publicKey = nostr.getPublicKey(privateKey) + this.generateKeyDialog.privateKey = privateKey + this.generateKeyDialog.nsec = nostr.nip19.nsecEncode(privateKey) + this.generateKeyDialog.npub = nostr.nip19.npubEncode(publicKey) + this.generateKeyDialog.showNsec = false + this.generateKeyDialog.show = true + }, + confirmGenerateKey: async function () { + this.generateKeyDialog.show = false + await this.createMerchant(this.generateKeyDialog.privateKey) }, importKeys: async function () { this.importKeyDialog.show = false - // Import keys functionality removed since we use user's native keypairs - // Show a message that this is no longer needed - this.$q.notify({ - type: 'info', - message: 'Merchants now use your account Nostr keys automatically. Key import is no longer needed.', - timeout: 3000 - }) + let privateKey = this.importKeyDialog.data.privateKey + if (!privateKey) { + return + } + try { + if (privateKey.toLowerCase().startsWith('nsec')) { + privateKey = nostr.nip19.decode(privateKey).data + } + // Check if this key is already in use + const publicKey = nostr.getPublicKey(privateKey) + if (this.merchant?.public_key === publicKey) { + this.$q.notify({ + type: 'warning', + message: 'This key is already your current profile' + }) + return + } + } catch (error) { + this.$q.notify({ + type: 'negative', + message: `${error}` + }) + return + } + await this.createMerchant(privateKey) }, showImportKeysDialog: async function () { this.importKeyDialog.show = true @@ -116,9 +143,12 @@ window.app = Vue.createApp({ this.showKeys = false this.stallCount = 0 }, - createMerchant: async function () { + createMerchant: async function (privateKey) { try { + const pubkey = nostr.getPublicKey(privateKey) const payload = { + private_key: privateKey, + public_key: pubkey, config: {} } const {data} = await LNbits.api.request( diff --git a/tasks.py b/tasks.py index c147936..013a281 100644 --- a/tasks.py +++ b/tasks.py @@ -1,5 +1,4 @@ import asyncio -import time from asyncio import Queue from lnbits.core.models import Payment @@ -10,13 +9,9 @@ from .nostr.nostr_client import NostrClient from .services import ( handle_order_paid, process_nostr_message, - resubscribe_to_all_merchants, subscribe_to_all_merchants, ) -HEALTH_CHECK_INTERVAL = 30 # seconds between health checks -STALE_THRESHOLD = 120 # seconds without events before resubscribing - async def wait_for_paid_invoices(): invoice_queue = Queue() @@ -40,38 +35,13 @@ async def on_invoice_paid(payment: Payment) -> None: async def wait_for_nostr_events(nostr_client: NostrClient): - logger.info("[NOSTRMARKET] Starting wait_for_nostr_events task") while True: try: - logger.info("[NOSTRMARKET] Subscribing to all merchants...") await subscribe_to_all_merchants() while True: message = await nostr_client.get_event() await process_nostr_message(message) except Exception as e: - logger.warning(f"[NOSTRMARKET] Subscription failed. Retrying in 10s: {e}") + logger.warning(f"Subcription failed. Will retry in one minute: {e}") await asyncio.sleep(10) - - -async def subscription_health_monitor(nostr_client: NostrClient): - """ - Periodically check if events are flowing. If no events have been - received for STALE_THRESHOLD seconds, force a resubscription with - overlap to catch any missed events. - """ - logger.info("[NOSTRMARKET] Starting subscription health monitor") - while True: - await asyncio.sleep(HEALTH_CHECK_INTERVAL) - try: - if not nostr_client.is_websocket_connected: - continue - - elapsed = time.time() - nostr_client.last_event_at - if nostr_client.last_event_at > 0 and elapsed > STALE_THRESHOLD: - logger.warning( - f"[NOSTRMARKET] ⚠️ No events for {elapsed:.0f}s, resubscribing..." - ) - await resubscribe_to_all_merchants() - except Exception as e: - logger.error(f"[NOSTRMARKET] Health monitor error: {e}") diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 22ffb83..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Stub out the nostrmarket root package and all LNbits dependencies so that -nostr/* unit tests can run without the full LNbits environment. - -pytest walks up from tests/ and tries to import the parent __init__.py, -which pulls in fastapi, lnbits, websocket, etc. We preemptively register -the parent package as a simple module so that import never happens. -""" - -import sys -import types -from pathlib import Path - -# Register 'nostrmarket' as an already-imported namespace package -# pointing at the extension root, so pytest doesn't try to exec __init__.py -_ext_root = Path(__file__).resolve().parent.parent -_pkg = types.ModuleType("nostrmarket") -_pkg.__path__ = [str(_ext_root)] -_pkg.__package__ = "nostrmarket" -sys.modules["nostrmarket"] = _pkg - -# Also ensure the nostr subpackage is importable -_nostr_dir = _ext_root / "nostr" -_nostr_pkg = types.ModuleType("nostrmarket.nostr") -_nostr_pkg.__path__ = [str(_nostr_dir)] -_nostr_pkg.__package__ = "nostrmarket.nostr" -sys.modules["nostrmarket.nostr"] = _nostr_pkg diff --git a/tests/test_nip44.py b/tests/test_nip44.py deleted file mode 100644 index 3e767a6..0000000 --- a/tests/test_nip44.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Tests for NIP-44 v2 encryption against official spec test vectors.""" - -import coincurve -import pytest - -from nostr.nip44 import ( - calc_padded_len, - decrypt, - encrypt, - get_conversation_key, - get_message_keys, -) - - -def pubkey_from_secret(secret_hex: str) -> str: - """Derive x-only public key hex from secret key hex.""" - sk = coincurve.PrivateKey(bytes.fromhex(secret_hex)) - return sk.public_key.format(compressed=True)[1:].hex() - - -# --- Test vector from NIP-44 spec --- - -SPEC_VECTOR = { - "sec1": "0000000000000000000000000000000000000000000000000000000000000001", - "sec2": "0000000000000000000000000000000000000000000000000000000000000002", - "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", - "nonce": "0000000000000000000000000000000000000000000000000000000000000001", - "plaintext": "a", - "payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb", -} - - -class TestConversationKey: - def test_spec_vector(self): - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - assert key.hex() == SPEC_VECTOR["conversation_key"] - - def test_symmetric(self): - """conv(a, B) == conv(b, A)""" - pub1 = pubkey_from_secret(SPEC_VECTOR["sec1"]) - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - key_ab = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - key_ba = get_conversation_key(SPEC_VECTOR["sec2"], pub1) - assert key_ab == key_ba - - -class TestMessageKeys: - def test_returns_correct_lengths(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - nonce = bytes.fromhex(SPEC_VECTOR["nonce"]) - chacha_key, chacha_nonce, hmac_key = get_message_keys(conv_key, nonce) - assert len(chacha_key) == 32 - assert len(chacha_nonce) == 12 - assert len(hmac_key) == 32 - - def test_rejects_bad_key_length(self): - with pytest.raises(ValueError): - get_message_keys(b"\x00" * 16, b"\x00" * 32) - - def test_rejects_bad_nonce_length(self): - with pytest.raises(ValueError): - get_message_keys(b"\x00" * 32, b"\x00" * 16) - - -class TestPadding: - @pytest.mark.parametrize( - "unpadded,expected", - [ - (1, 32), - (2, 32), - (31, 32), - (32, 32), - (33, 64), - (64, 64), - (65, 96), - (256, 256), - (257, 320), - (1024, 1024), - (65535, 65536), - ], - ) - def test_calc_padded_len(self, unpadded, expected): - assert calc_padded_len(unpadded) == expected - - def test_rejects_zero(self): - with pytest.raises(ValueError): - calc_padded_len(0) - - -class TestEncryptDecrypt: - def test_spec_vector(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - nonce = bytes.fromhex(SPEC_VECTOR["nonce"]) - payload = encrypt(SPEC_VECTOR["plaintext"], conv_key, nonce) - assert payload == SPEC_VECTOR["payload"] - - def test_spec_vector_decrypt(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - plaintext = decrypt(SPEC_VECTOR["payload"], conv_key) - assert plaintext == SPEC_VECTOR["plaintext"] - - def test_round_trip_short(self): - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - msg = "x" - assert decrypt(encrypt(msg, conv_key), conv_key) == msg - - def test_round_trip_long(self): - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - msg = "A" * 65535 - assert decrypt(encrypt(msg, conv_key), conv_key) == msg - - def test_round_trip_unicode(self): - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - msg = "hello world! \U0001f680\U0001f30e\U0001f4ac" - assert decrypt(encrypt(msg, conv_key), conv_key) == msg - - def test_tampered_mac_rejected(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - payload = SPEC_VECTOR["payload"] - tampered = payload[:-1] + ("a" if payload[-1] != "a" else "b") - with pytest.raises(ValueError, match="invalid MAC"): - decrypt(tampered, conv_key) - - def test_empty_plaintext_rejected(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - with pytest.raises(ValueError, match="invalid plaintext length"): - encrypt("", conv_key) - - def test_unknown_version_rejected(self): - with pytest.raises(ValueError, match="unknown version"): - decrypt("#invalid", bytes(32)) - - def test_short_payload_rejected(self): - with pytest.raises(ValueError, match="invalid payload size"): - decrypt("AAAA", bytes(32)) diff --git a/tests/test_nip59.py b/tests/test_nip59.py deleted file mode 100644 index e518abf..0000000 --- a/tests/test_nip59.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Tests for NIP-59 gift wrap protocol.""" - -import json -import time - -import coincurve -import pytest - -from nostr.nip59 import ( - create_gift_wrap, - create_rumor, - create_seal, - unseal, - unwrap_gift_wrap, - unwrap_message, - wrap_message, -) - - -def _generate_keypair() -> tuple[str, str]: - """Generate a (privkey_hex, pubkey_hex) pair.""" - sk = coincurve.PrivateKey() - privkey = sk.secret.hex() - pubkey = sk.public_key.format(compressed=True)[1:].hex() - return privkey, pubkey - - -SENDER_PRIV, SENDER_PUB = _generate_keypair() -RECIPIENT_PRIV, RECIPIENT_PUB = _generate_keypair() - - -class TestCreateRumor: - def test_has_id_but_no_sig(self): - rumor = create_rumor(SENDER_PUB, "hello", kind=14) - assert rumor.id != "" - assert rumor.sig is None - - def test_kind_and_content(self): - rumor = create_rumor(SENDER_PUB, "test message", kind=14, tags=[["p", RECIPIENT_PUB]]) - assert rumor.kind == 14 - assert rumor.content == "test message" - assert rumor.pubkey == SENDER_PUB - assert ["p", RECIPIENT_PUB] in rumor.tags - - def test_custom_timestamp(self): - ts = 1700000000 - rumor = create_rumor(SENDER_PUB, "hello", created_at=ts) - assert rumor.created_at == ts - - -class TestCreateSeal: - def test_kind_13_with_empty_tags(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) - assert seal.kind == 13 - assert seal.tags == [] - assert seal.pubkey == SENDER_PUB - - def test_is_signed(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) - assert seal.sig is not None - assert len(seal.sig) == 128 # 64 bytes hex - - def test_content_is_encrypted(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) - # Content should be base64 NIP-44 payload, not plaintext JSON - assert "hello" not in seal.content - - def test_timestamp_is_randomized(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) - now = int(time.time()) - # Seal timestamp should be in the past (up to 2 days) - assert seal.created_at <= now - assert seal.created_at >= now - (2 * 24 * 60 * 60 + 10) - - -class TestCreateGiftWrap: - def test_kind_1059_with_p_tag(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) - wrap = create_gift_wrap(seal, RECIPIENT_PUB) - assert wrap.kind == 1059 - assert ["p", RECIPIENT_PUB] in wrap.tags - - def test_uses_ephemeral_key(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) - wrap = create_gift_wrap(seal, RECIPIENT_PUB) - # Gift wrap pubkey should be neither sender nor recipient - assert wrap.pubkey != SENDER_PUB - assert wrap.pubkey != RECIPIENT_PUB - - def test_different_wraps_have_different_ephemeral_keys(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) - wrap1 = create_gift_wrap(seal, RECIPIENT_PUB) - wrap2 = create_gift_wrap(seal, RECIPIENT_PUB) - assert wrap1.pubkey != wrap2.pubkey - - -class TestUnwrap: - def test_unwrap_gift_wrap_returns_seal(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) - wrap = create_gift_wrap(seal, RECIPIENT_PUB) - - recovered_seal = unwrap_gift_wrap(wrap, RECIPIENT_PRIV) - assert recovered_seal.kind == 13 - assert recovered_seal.pubkey == SENDER_PUB - - def test_unseal_returns_rumor(self): - rumor = create_rumor(SENDER_PUB, "hello world") - seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) - - recovered_rumor = unseal(seal, RECIPIENT_PRIV) - assert recovered_rumor.content == "hello world" - assert recovered_rumor.pubkey == SENDER_PUB - assert recovered_rumor.kind == 14 - - def test_wrong_key_fails(self): - rumor = create_rumor(SENDER_PUB, "secret") - seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) - wrap = create_gift_wrap(seal, RECIPIENT_PUB) - - wrong_priv, _ = _generate_keypair() - with pytest.raises(Exception): - unwrap_message(wrap, wrong_priv) - - -class TestFullRoundTrip: - def test_wrap_unwrap_message(self): - content = "Are you going to the party tonight?" - wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB) - - assert wrap.kind == 1059 - assert ["p", RECIPIENT_PUB] in wrap.tags - - rumor = unwrap_message(wrap, RECIPIENT_PRIV) - assert rumor.content == content - assert rumor.pubkey == SENDER_PUB - assert rumor.kind == 14 - assert rumor.sig is None - - def test_wrap_with_custom_kind_and_tags(self): - tags = [["p", RECIPIENT_PUB], ["subject", "test"]] - wrap = wrap_message( - "order data", - SENDER_PRIV, - SENDER_PUB, - RECIPIENT_PUB, - kind=14, - tags=tags, - ) - - rumor = unwrap_message(wrap, RECIPIENT_PRIV) - assert rumor.content == "order data" - assert rumor.kind == 14 - assert ["subject", "test"] in rumor.tags - - def test_self_wrap_for_archival(self): - """Merchant wraps a copy to self (same sender and recipient).""" - content = '{"type": 1, "payment_options": [{"type": "ln", "link": "lnbc..."}]}' - wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, SENDER_PUB) - - rumor = unwrap_message(wrap, SENDER_PRIV) - assert rumor.content == content - assert rumor.pubkey == SENDER_PUB - - def test_json_content_preserved(self): - """Order JSON payloads survive the wrap/unwrap cycle.""" - order = { - "type": 0, - "id": "test-order-123", - "items": [{"product_id": "abc", "quantity": 2}], - "shipping_id": "zone-1", - } - content = json.dumps(order) - wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB) - - rumor = unwrap_message(wrap, RECIPIENT_PRIV) - recovered_order = json.loads(rumor.content) - assert recovered_order == order - - def test_unicode_content(self): - content = "Payment received! \u2705 Your order is being processed \U0001f4e6" - wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB) - rumor = unwrap_message(wrap, RECIPIENT_PRIV) - assert rumor.content == content diff --git a/views_api.py b/views_api.py index f974345..1e9f5c5 100644 --- a/views_api.py +++ b/views_api.py @@ -1,18 +1,15 @@ import json from http import HTTPStatus -from typing import List, Optional from fastapi import Depends from fastapi.exceptions import HTTPException -from lnbits.core.crud import get_account, update_account +from lnbits.core.models import WalletTypeInfo from lnbits.core.services import websocket_updater from lnbits.decorators import ( - WalletTypeInfo, require_admin_key, require_invoice_key, ) from lnbits.utils.exchange_rates import currencies -from lnbits.utils.nostr import generate_keypair from loguru import logger from . import nostr_client, nostrmarket_ext @@ -61,7 +58,6 @@ from .crud import ( ) from .helpers import normalize_public_key from .models import ( - CreateMerchantRequest, Customer, DirectMessage, DirectMessageType, @@ -84,7 +80,6 @@ from .services import ( create_or_update_order_from_dm, reply_to_structured_dm, resubscribe_to_all_merchants, - send_dm, sign_and_send_to_nostr, subscribe_to_all_merchants, update_merchant_to_nostr, @@ -95,48 +90,15 @@ from .services import ( @nostrmarket_ext.post("/api/v1/merchant") async def api_create_merchant( - data: CreateMerchantRequest, + data: PartialMerchant, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> Merchant: try: - # Check if merchant already exists for this user - merchant = await get_merchant_for_user(wallet.wallet.user) - assert merchant is None, "A merchant already exists for this user" + merchant = await get_merchant_by_pubkey(data.public_key) + assert merchant is None, "A merchant already uses this public key" - # Get user's account to access their Nostr keypairs - account = await get_account(wallet.wallet.user) - if not account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="User account not found", - ) - - # Check if user has Nostr keypairs, generate them if not - if not account.pubkey or not account.prvkey: - # Generate new keypair for user - private_key, public_key = generate_keypair() - - # Update user account with new keypairs - account.pubkey = public_key - account.prvkey = private_key - await update_account(account) - else: - public_key = account.pubkey - private_key = account.prvkey - - # Check if another merchant is already using this public key - existing_merchant = await get_merchant_by_pubkey(public_key) - assert existing_merchant is None, "A merchant already uses this public key" - - # Create PartialMerchant with user's keypairs - partial_merchant = PartialMerchant( - private_key=private_key, - public_key=public_key, - config=data.config - ) - - merchant = await create_merchant(wallet.wallet.user, partial_merchant) + merchant = await create_merchant(wallet.wallet.user, data) await create_zone( merchant.id, @@ -151,7 +113,7 @@ async def api_create_merchant( await resubscribe_to_all_merchants() - await nostr_client.merchant_temp_subscription(public_key) + await nostr_client.merchant_temp_subscription(data.public_key) return merchant except AssertionError as ex: @@ -170,7 +132,7 @@ async def api_create_merchant( @nostrmarket_ext.get("/api/v1/merchant") async def api_get_merchant( wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> Optional[Merchant]: +) -> Merchant | None: try: merchant = await get_merchant_for_user(wallet.wallet.user) @@ -367,7 +329,7 @@ async def api_delete_merchant_on_nostr( @nostrmarket_ext.get("/api/v1/zone") async def api_get_zones( wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> List[Zone]: +) -> list[Zone]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -567,7 +529,7 @@ async def api_get_stall( @nostrmarket_ext.get("/api/v1/stall") async def api_get_stalls( - pending: Optional[bool] = False, + pending: bool | None = False, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -591,7 +553,7 @@ async def api_get_stalls( @nostrmarket_ext.get("/api/v1/stall/product/{stall_id}") async def api_get_stall_products( stall_id: str, - pending: Optional[bool] = False, + pending: bool | None = False, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -615,9 +577,9 @@ async def api_get_stall_products( @nostrmarket_ext.get("/api/v1/stall/order/{stall_id}") async def api_get_stall_orders( stall_id: str, - paid: Optional[bool] = None, - shipped: Optional[bool] = None, - pubkey: Optional[str] = None, + paid: bool | None = None, + shipped: bool | None = None, + pubkey: str | None = None, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -751,7 +713,7 @@ async def api_update_product( async def api_get_product( product_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> Optional[Product]: +) -> Product | None: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -836,9 +798,9 @@ async def api_get_order( @nostrmarket_ext.get("/api/v1/order") async def api_get_orders( - paid: Optional[bool] = None, - shipped: Optional[bool] = None, - pubkey: Optional[str] = None, + paid: bool | None = None, + shipped: bool | None = None, + pubkey: str | None = None, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -882,11 +844,27 @@ async def api_update_order_status( ensure_ascii=False, ) - await send_dm( - merchant, - order.public_key, - DirectMessageType.ORDER_PAID_OR_SHIPPED.value, - dm_content, + dm_event = merchant.build_dm_event(dm_content, order.public_key) + + dm = PartialDirectMessage( + event_id=dm_event.id, + event_created_at=dm_event.created_at, + message=dm_content, + public_key=order.public_key, + type=DirectMessageType.ORDER_PAID_OR_SHIPPED.value, + ) + await create_direct_message(merchant.id, dm) + + await nostr_client.publish_nostr_event(dm_event) + await websocket_updater( + merchant.id, + json.dumps( + { + "type": f"dm:{dm.type}", + "customerPubkey": order.public_key, + "dm": dm.dict(), + } + ), ) return order @@ -908,7 +886,7 @@ async def api_update_order_status( async def api_restore_order( event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), -) -> Optional[Order]: +) -> Order | None: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -1035,7 +1013,7 @@ async def api_reissue_order_invoice( @nostrmarket_ext.get("/api/v1/message/{public_key}") async def api_get_messages( public_key: str, wallet: WalletTypeInfo = Depends(require_invoice_key) -) -> List[DirectMessage]: +) -> list[DirectMessage]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -1064,13 +1042,14 @@ async def api_create_message( merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" - dm_reply = await send_dm( - merchant, - data.public_key, - data.type, - data.message, - ) - return dm_reply + dm_event = merchant.build_dm_event(data.message, data.public_key) + data.event_id = dm_event.id + data.event_created_at = dm_event.created_at + + dm = await create_direct_message(merchant.id, data) + await nostr_client.publish_nostr_event(dm_event) + + return dm except AssertionError as ex: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -1090,7 +1069,7 @@ async def api_create_message( @nostrmarket_ext.get("/api/v1/customer") async def api_get_customers( wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> List[Customer]: +) -> list[Customer]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found"