diff --git a/crud.py b/crud.py index 4cb18c2..17282d7 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]}" @@ -654,7 +651,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]}" @@ -678,7 +675,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}, @@ -692,7 +689,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 @@ -756,7 +753,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 @@ -772,7 +769,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 @@ -786,7 +783,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 @@ -798,7 +795,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 @@ -859,7 +856,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 @@ -873,7 +870,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}, @@ -881,7 +878,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/helpers.py b/helpers.py index 69d1f2f..bb5efaf 100644 --- a/helpers.py +++ b/helpers.py @@ -1,6 +1,5 @@ import base64 import secrets -from typing import Optional import secp256k1 from bech32 import bech32_decode, convertbits @@ -33,7 +32,7 @@ def decrypt_message(encoded_message: str, encryption_key) -> str: return unpadded_data.decode() -def encrypt_message(message: str, encryption_key, iv: Optional[bytes] = None) -> str: +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() 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 a6f3c3f..f1af073 100644 --- a/models.py +++ b/models.py @@ -2,7 +2,7 @@ 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 @@ -32,21 +32,16 @@ class Nostrable: class MerchantProfile(BaseModel): - name: Optional[str] = None - about: Optional[str] = None - picture: Optional[str] = None + name: str | None = None + about: str | None = None + picture: str | None = None class MerchantConfig(MerchantProfile): - event_id: Optional[str] = None - sync_from_nostr = 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() + event_id: str | None = None + sync_from_nostr: bool = False + active: bool = False + restore_in_progress: bool | None = False class PartialMerchant(BaseModel): @@ -57,7 +52,7 @@ 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_) @@ -127,11 +122,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": @@ -144,22 +139,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: @@ -217,19 +212,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 @@ -237,8 +232,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 = { @@ -295,7 +290,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": @@ -312,21 +307,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 @@ -342,19 +337,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 @@ -375,8 +370,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( @@ -405,7 +400,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]" @@ -454,7 +449,7 @@ class Order(PartialOrder): total: float paid: bool = False shipped: bool = False - time: Optional[int] = None + time: int | None = None extra: OrderExtra @classmethod @@ -468,14 +463,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): @@ -485,8 +480,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 ####################################### @@ -502,16 +497,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: @@ -534,15 +529,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/nostr_client.py b/nostr/nostr_client.py index 967bc1b..a611980 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -31,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, @@ -67,7 +65,6 @@ class NostrClient: 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 return value @@ -94,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) @@ -174,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 05461fc..4039dbb 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 @@ -60,12 +59,11 @@ from .nostr.event import NostrEvent 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 @@ -75,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}'" @@ -100,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) @@ -111,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", @@ -145,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 +221,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 @@ -298,9 +289,9 @@ async def send_dm( 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( @@ -323,17 +314,11 @@ 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) - if event.kind == 0: await _handle_customer_profile_update(event) elif event.kind == 4: @@ -342,15 +327,10 @@ async def process_nostr_message(msg: str): 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( @@ -432,29 +412,28 @@ async def extract_customer_order_from_dm( async def _handle_nip04_message(event: NostrEvent): - - p_tags = event.tag_values("p") - - # PRIORITY 1: Check if any recipient (p_tag) is a merchant → incoming message TO merchant - for p_tag in p_tags: - if p_tag: - potential_merchant = await get_merchant_by_pubkey(p_tag) - if potential_merchant: - clear_text_msg = potential_merchant.decrypt_message(event.content, event.pubkey) - await _handle_incoming_dms(event, potential_merchant, clear_text_msg) - return # IMPORTANT: Return immediately to prevent double processing - - # PRIORITY 2: If no recipient merchant found, check if sender is a merchant → outgoing message FROM merchant - sender_merchant = await get_merchant_by_pubkey(event.pubkey) - if sender_merchant: - assert len(event.tag_values("p")) != 0, "Outgoing message has no 'p' tag" - clear_text_msg = sender_merchant.decrypt_message( + merchant_public_key = event.pubkey + merchant = await get_merchant_by_pubkey(merchant_public_key) + + if not merchant: + 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] ) - await _handle_outgoing_dms(event, sender_merchant, clear_text_msg) - return # IMPORTANT: Return immediately - - # No merchant found in either direction + 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( @@ -504,18 +483,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 @@ -596,31 +574,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, @@ -628,17 +584,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( @@ -671,11 +622,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 0d01f82..3d89779 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -21,18 +21,26 @@ 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() + const privateKey = nostr.generatePrivateKey() + await this.createMerchant(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 + } + } catch (error) { + this.$q.notify({ + type: 'negative', + message: `${error}` + }) + } + await this.createMerchant(privateKey) }, showImportKeysDialog: async function () { this.importKeyDialog.show = true @@ -86,9 +94,12 @@ window.app = Vue.createApp({ this.activeChatCustomer = '' this.showKeys = false }, - 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 774951f..013a281 100644 --- a/tasks.py +++ b/tasks.py @@ -35,17 +35,13 @@ async def on_invoice_paid(payment: Payment) -> None: async def wait_for_nostr_events(nostr_client: NostrClient): - logger.info("[NOSTRMARKET DEBUG] Starting wait_for_nostr_events task") while True: try: - logger.info("[NOSTRMARKET DEBUG] Subscribing to all merchants...") await subscribe_to_all_merchants() while True: - logger.debug("[NOSTRMARKET DEBUG] Waiting for nostr event...") message = await nostr_client.get_event() - logger.info(f"[NOSTRMARKET DEBUG] Received event from nostr_client: {message[:100]}...") await process_nostr_message(message) except Exception as e: - logger.warning(f"[NOSTRMARKET DEBUG] Subscription failed. Will retry in 10 seconds: {e}") + logger.warning(f"Subcription failed. Will retry in one minute: {e}") await asyncio.sleep(10) diff --git a/views_api.py b/views_api.py index f858397..af675cd 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, @@ -93,48 +89,18 @@ 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_by_pubkey(data.public_key) + assert merchant is None, "A merchant already uses this public key" + merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant is None, "A merchant already exists for this user" - # 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, @@ -149,7 +115,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: @@ -168,7 +134,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) @@ -336,7 +302,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" @@ -536,7 +502,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: @@ -560,7 +526,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: @@ -584,9 +550,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: @@ -720,7 +686,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" @@ -805,9 +771,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: @@ -893,7 +859,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" @@ -1020,7 +986,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" @@ -1076,7 +1042,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"