diff --git a/crud.py b/crud.py
index 3bec1f2..adc0836 100644
--- a/crud.py
+++ b/crud.py
@@ -1,4 +1,5 @@
import json
+from typing import List, Optional, Tuple
from lnbits.helpers import urlsafe_short_hash
@@ -43,7 +44,7 @@ async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant:
async def update_merchant(
user_id: str, merchant_id: str, config: MerchantConfig
-) -> Merchant | None:
+) -> Optional[Merchant]:
await db.execute(
f"""
UPDATE nostrmarket.merchants SET meta = :meta, time = {db.timestamp_now}
@@ -54,7 +55,7 @@ async def update_merchant(
return await get_merchant(user_id, merchant_id)
-async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None:
+async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
await db.execute(
f"""
UPDATE nostrmarket.merchants SET time = {db.timestamp_now}
@@ -65,7 +66,7 @@ async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None:
return await get_merchant(user_id, merchant_id)
-async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None:
+async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
row: dict = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id AND id = :id""",
{
@@ -77,7 +78,7 @@ async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None:
return Merchant.from_row(row) if row else None
-async def get_merchant_by_pubkey(public_key: str) -> Merchant | None:
+async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]:
row: dict = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE public_key = :public_key""",
{"public_key": public_key},
@@ -86,7 +87,7 @@ async def get_merchant_by_pubkey(public_key: str) -> Merchant | None:
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""",
)
@@ -94,7 +95,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) -> Merchant | None:
+async def get_merchant_for_user(user_id: str) -> Optional[Merchant]:
row: dict = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id """,
{"user_id": user_id},
@@ -137,7 +138,7 @@ async def create_zone(merchant_id: str, data: Zone) -> Zone:
return zone
-async def update_zone(merchant_id: str, z: Zone) -> Zone | None:
+async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]:
await db.execute(
"""
UPDATE nostrmarket.zones
@@ -156,7 +157,7 @@ async def update_zone(merchant_id: str, z: Zone) -> Zone | None:
return await get_zone(merchant_id, z.id)
-async def get_zone(merchant_id: str, zone_id: str) -> Zone | None:
+async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]:
row: dict = await db.fetchone(
"SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id AND id = :id",
{
@@ -167,7 +168,7 @@ async def get_zone(merchant_id: str, zone_id: str) -> Zone | None:
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},
@@ -234,7 +235,7 @@ async def create_stall(merchant_id: str, data: Stall) -> Stall:
return stall
-async def get_stall(merchant_id: str, stall_id: str) -> Stall | None:
+async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.stalls
@@ -248,7 +249,7 @@ async def get_stall(merchant_id: str, stall_id: str) -> Stall | None:
return Stall.from_row(row) if row else None
-async def get_stalls(merchant_id: str, pending: bool | None = False) -> list[Stall]:
+async def get_stalls(merchant_id: str, pending: Optional[bool] = False) -> List[Stall]:
rows: list[dict] = await db.fetchall(
"""
SELECT * FROM nostrmarket.stalls
@@ -273,7 +274,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) -> Stall | None:
+async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]:
await db.execute(
"""
UPDATE nostrmarket.stalls
@@ -397,7 +398,9 @@ async def update_product(merchant_id: str, product: Product) -> Product:
return updated_product
-async def update_product_quantity(product_id: str, new_quantity: int) -> Product | None:
+async def update_product_quantity(
+ product_id: str, new_quantity: int
+) -> Optional[Product]:
await db.execute(
"""
UPDATE nostrmarket.products SET quantity = :quantity
@@ -412,7 +415,7 @@ async def update_product_quantity(product_id: str, new_quantity: int) -> Product
return Product.from_row(row) if row else None
-async def get_product(merchant_id: str, product_id: str) -> Product | None:
+async def get_product(merchant_id: str, product_id: str) -> Optional[Product]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.products
@@ -428,8 +431,8 @@ async def get_product(merchant_id: str, product_id: str) -> Product | None:
async def get_products(
- merchant_id: str, stall_id: str, pending: bool | None = False
-) -> list[Product]:
+ merchant_id: str, stall_id: str, pending: Optional[bool] = False
+) -> List[Product]:
rows: list[dict] = await db.fetchall(
"""
SELECT * FROM nostrmarket.products
@@ -442,8 +445,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 = []
@@ -464,7 +467,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) -> str | None:
+async def get_wallet_for_product(product_id: str) -> Optional[str]:
row: dict = await db.fetchone(
"""
SELECT s.wallet as wallet FROM nostrmarket.products p
@@ -571,7 +574,7 @@ async def create_order(merchant_id: str, o: Order) -> Order:
return order
-async def get_order(merchant_id: str, order_id: str) -> Order | None:
+async def get_order(merchant_id: str, order_id: str) -> Optional[Order]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.orders
@@ -585,7 +588,7 @@ async def get_order(merchant_id: str, order_id: str) -> Order | None:
return Order.from_row(row) if row else None
-async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None:
+async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Order]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.orders
@@ -599,7 +602,7 @@ async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None
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]}"
@@ -626,7 +629,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]}"
@@ -652,7 +655,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) -> Order | None:
+async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Order]:
q = ", ".join(
[
f"{field[0]} = :{field[0]}"
@@ -676,7 +679,7 @@ async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | Non
return await get_order(merchant_id, order_id)
-async def update_order_paid_status(order_id: str, paid: bool) -> Order | None:
+async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]:
await db.execute(
"UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id",
{"paid": paid, "id": order_id},
@@ -690,7 +693,7 @@ async def update_order_paid_status(order_id: str, paid: bool) -> Order | None:
async def update_order_shipped_status(
merchant_id: str, order_id: str, shipped: bool
-) -> Order | None:
+) -> Optional[Order]:
await db.execute(
"""
UPDATE nostrmarket.orders
@@ -754,7 +757,7 @@ async def create_direct_message(
return msg
-async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | None:
+async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMessage]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.direct_messages
@@ -770,7 +773,7 @@ async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | No
async def get_direct_message_by_event_id(
merchant_id: str, event_id: str
-) -> DirectMessage | None:
+) -> Optional[DirectMessage]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.direct_messages
@@ -784,7 +787,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
@@ -796,7 +799,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
@@ -857,7 +860,7 @@ async def create_customer(merchant_id: str, data: Customer) -> Customer:
return customer
-async def get_customer(merchant_id: str, public_key: str) -> Customer | None:
+async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.customers
@@ -871,7 +874,7 @@ async def get_customer(merchant_id: str, public_key: str) -> Customer | None:
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},
@@ -879,7 +882,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 dcc0f06..dd26116 100644
--- a/helpers.py
+++ b/helpers.py
@@ -1,5 +1,6 @@
import base64
import secrets
+from typing import Optional
import coincurve
from bech32 import bech32_decode, convertbits
@@ -36,7 +37,7 @@ def decrypt_message(encoded_message: str, encryption_key) -> str:
return unpadded_data.decode()
-def encrypt_message(message: str, encryption_key, iv: bytes | None = None) -> str:
+def encrypt_message(message: str, encryption_key, iv: Optional[bytes] = 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
new file mode 100644
index 0000000..de393e2
--- /dev/null
+++ b/misc-docs/ORDER-DISCOVERY-ANALYSIS.md
@@ -0,0 +1,320 @@
+# 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 f1af073..b12b775 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
+from typing import Any, List, Optional, Tuple
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
from pydantic import BaseModel
@@ -32,16 +32,26 @@ class Nostrable:
class MerchantProfile(BaseModel):
- name: str | None = None
- about: str | None = None
- picture: str | None = None
+ 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
class MerchantConfig(MerchantProfile):
- event_id: str | None = None
+ event_id: Optional[str] = None
sync_from_nostr: bool = False
- active: bool = False
- restore_in_progress: bool | None = 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()
class PartialMerchant(BaseModel):
@@ -52,7 +62,7 @@ class PartialMerchant(BaseModel):
class Merchant(PartialMerchant, Nostrable):
id: str
- time: int | None = 0
+ time: Optional[int] = 0
def sign_hash(self, hash_: bytes) -> str:
return sign_message_hash(self.private_key, hash_)
@@ -86,11 +96,23 @@ class Merchant(PartialMerchant, Nostrable):
return merchant
def to_nostr_event(self, pubkey: str) -> NostrEvent:
- content = {
- "name": self.config.name,
- "about": self.config.about,
- "picture": self.config.picture,
- }
+ content: dict[str, str] = {}
+ if self.config.name:
+ content["name"] = self.config.name
+ if self.config.display_name:
+ content["display_name"] = self.config.display_name
+ if self.config.about:
+ content["about"] = self.config.about
+ if self.config.picture:
+ content["picture"] = self.config.picture
+ if self.config.banner:
+ content["banner"] = self.config.banner
+ if self.config.website:
+ content["website"] = self.config.website
+ if self.config.nip05:
+ content["nip05"] = self.config.nip05
+ if self.config.lud16:
+ content["lud16"] = self.config.lud16
event = NostrEvent(
pubkey=pubkey,
created_at=round(time.time()),
@@ -122,11 +144,11 @@ class Merchant(PartialMerchant, Nostrable):
######################################## ZONES ########################################
class Zone(BaseModel):
- id: str | None = None
- name: str | None = None
+ id: Optional[str] = None
+ name: Optional[str] = None
currency: str
cost: float
- countries: list[str] = []
+ countries: List[str] = []
@classmethod
def from_row(cls, row: dict) -> "Zone":
@@ -139,22 +161,22 @@ class Zone(BaseModel):
class StallConfig(BaseModel):
- image_url: str | None = None
- description: str | None = None
+ image_url: Optional[str] = None
+ description: Optional[str] = None
class Stall(BaseModel, Nostrable):
- id: str | None = None
+ id: Optional[str] = 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: str | None = None
- event_created_at: int | None = None
+ event_id: Optional[str] = None
+ event_created_at: Optional[int] = None
def validate_stall(self):
for z in self.shipping_zones:
@@ -212,19 +234,19 @@ class ProductShippingCost(BaseModel):
class ProductConfig(BaseModel):
- description: str | None = None
- currency: str | None = None
- use_autoreply: bool | None = False
- autoreply_message: str | None = None
- shipping: list[ProductShippingCost] = []
+ description: Optional[str] = None
+ currency: Optional[str] = None
+ use_autoreply: Optional[bool] = False
+ autoreply_message: Optional[str] = None
+ shipping: List[ProductShippingCost] = []
class Product(BaseModel, Nostrable):
- id: str | None = None
+ id: Optional[str] = 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
@@ -232,8 +254,8 @@ class Product(BaseModel, Nostrable):
config: ProductConfig = ProductConfig()
"""Last published nostr event for this Product"""
- event_id: str | None = None
- event_created_at: int | None = None
+ event_id: Optional[str] = None
+ event_created_at: Optional[int] = None
def to_nostr_event(self, pubkey: str) -> NostrEvent:
content = {
@@ -290,7 +312,7 @@ class ProductOverview(BaseModel):
id: str
name: str
price: float
- product_shipping_cost: float | None = None
+ product_shipping_cost: Optional[float] = None
@classmethod
def from_product(cls, p: Product) -> "ProductOverview":
@@ -307,21 +329,21 @@ class OrderItem(BaseModel):
class OrderContact(BaseModel):
- nostr: str | None = None
- phone: str | None = None
- email: str | None = None
+ nostr: Optional[str] = None
+ phone: Optional[str] = None
+ email: Optional[str] = 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: str | None = None
+ fail_message: Optional[str] = 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
@@ -337,19 +359,19 @@ class OrderExtra(BaseModel):
class PartialOrder(BaseModel):
id: str
- event_id: str | None = None
- event_created_at: int | None = None
+ event_id: Optional[str] = None
+ event_created_at: Optional[int] = None
public_key: str
merchant_public_key: str
shipping_id: str
- items: list[OrderItem]
- contact: OrderContact | None = None
- address: str | None = None
+ items: List[OrderItem]
+ contact: Optional[OrderContact] = None
+ address: Optional[str] = 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
@@ -370,8 +392,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(
@@ -400,7 +422,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]"
@@ -449,7 +471,7 @@ class Order(PartialOrder):
total: float
paid: bool = False
shipped: bool = False
- time: int | None = None
+ time: Optional[int] = None
extra: OrderExtra
@classmethod
@@ -463,14 +485,14 @@ class Order(PartialOrder):
class OrderStatusUpdate(BaseModel):
id: str
- message: str | None = None
- paid: bool | None = False
- shipped: bool | None = None
+ message: Optional[str] = None
+ paid: Optional[bool] = False
+ shipped: Optional[bool] = None
class OrderReissue(BaseModel):
id: str
- shipping_id: str | None = None
+ shipping_id: Optional[str] = None
class PaymentOption(BaseModel):
@@ -480,8 +502,8 @@ class PaymentOption(BaseModel):
class PaymentRequest(BaseModel):
id: str
- message: str | None = None
- payment_options: list[PaymentOption]
+ message: Optional[str] = None
+ payment_options: List[PaymentOption]
######################################## MESSAGE #######################################
@@ -497,16 +519,16 @@ class DirectMessageType(Enum):
class PartialDirectMessage(BaseModel):
- event_id: str | None = None
- event_created_at: int | None = None
+ event_id: Optional[str] = None
+ event_created_at: Optional[int] = None
message: str
public_key: str
type: int = DirectMessageType.PLAIN_TEXT.value
incoming: bool = False
- time: int | None = None
+ time: Optional[int] = None
@classmethod
- def parse_message(cls, msg) -> tuple[DirectMessageType, Any | None]:
+ def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]:
try:
msg_json = json.loads(msg)
if "type" in msg_json:
@@ -529,15 +551,15 @@ class DirectMessage(PartialDirectMessage):
class CustomerProfile(BaseModel):
- name: str | None = None
- about: str | None = None
+ name: Optional[str] = None
+ about: Optional[str] = None
class Customer(BaseModel):
merchant_id: str
public_key: str
- event_created_at: int | None = None
- profile: CustomerProfile | None = None
+ event_created_at: Optional[int] = None
+ profile: Optional[CustomerProfile] = None
unread_messages: int = 0
@classmethod
diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py
index a611980..967bc1b 100644
--- a/nostr/nostr_client.py
+++ b/nostr/nostr_client.py
@@ -31,9 +31,11 @@ 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(
- f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}",
+ ws_url,
on_message=on_message,
on_open=on_open,
on_close=on_close,
@@ -65,6 +67,7 @@ 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
@@ -91,10 +94,6 @@ 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)
@@ -175,16 +174,21 @@ class NostrClient:
def _ws_handlers(self):
def on_open(_):
- logger.info("Connected to 'nostrclient' websocket")
+ logger.debug("[NOSTRMARKET DEBUG] ✅ Connected to 'nostrclient' websocket successfully")
def on_message(_, message):
- self.recieve_event_queue.put_nowait(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}")
def on_error(_, error):
- logger.warning(error)
+ logger.warning(f"[NOSTRMARKET] ❌ Websocket error: {error}")
def on_close(x, status_code, message):
- logger.warning(f"Websocket closed: {x}: '{status_code}' '{message}'")
+ logger.warning(f"[NOSTRMARKET] 🔌 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 4039dbb..3057292 100644
--- a/services.py
+++ b/services.py
@@ -1,7 +1,8 @@
import asyncio
import json
+from typing import List, Optional, Tuple
-from bolt11 import decode
+from lnbits.bolt11 import decode
from lnbits.core.crud import get_wallet
from lnbits.core.services import create_invoice, websocket_updater
from loguru import logger
@@ -59,11 +60,12 @@ from .nostr.event import NostrEvent
async def create_new_order(
merchant_public_key: str, data: PartialOrder
-) -> PaymentRequest | None:
+) -> Optional[PaymentRequest]:
merchant = await get_merchant_by_pubkey(merchant_public_key)
assert merchant, "Cannot find merchant for order!"
- if await get_order(merchant.id, data.id):
+ existing_order = await get_order(merchant.id, data.id)
+ if existing_order:
return None
if data.event_id and await get_order_by_event_id(merchant.id, data.event_id):
return None
@@ -73,20 +75,24 @@ async def create_new_order(
)
await create_order(merchant.id, order)
- return PaymentRequest(
+ payment_request = 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}'"
@@ -94,6 +100,7 @@ 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)
@@ -104,11 +111,13 @@ 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=round(product_cost_sat + shipping_cost_sat),
+ amount=total_amount_sat,
memo=f"Order '{data.id}' for pubkey '{data.public_key}'",
extra={
"tag": "nostrmarket",
@@ -136,7 +145,7 @@ async def update_merchant_to_nostr(
merchant: Merchant, delete_merchant=False
) -> Merchant:
stalls = await get_stalls(merchant.id)
- event: NostrEvent | None = None
+ event: Optional[NostrEvent] = None
for stall in stalls:
assert stall.id
products = await get_products(merchant.id, stall.id)
@@ -149,9 +158,8 @@ async def update_merchant_to_nostr(
stall.event_id = event.id
stall.event_created_at = event.created_at
await update_stall(merchant.id, stall)
- if delete_merchant:
- # merchant profile updates not supported yet
- event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant)
+ # Always publish merchant profile (kind 0)
+ event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant)
assert event
merchant.config.event_id = event.id
return merchant
@@ -221,7 +229,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
@@ -289,9 +297,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(
@@ -314,11 +322,17 @@ async def compute_products_new_quantity(
async def process_nostr_message(msg: str):
try:
- type_, *rest = json.loads(msg)
+ parsed_msg = json.loads(msg)
+ type_, *rest = parsed_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:
@@ -327,10 +341,15 @@ 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.debug(ex)
+ logger.error(f"[NOSTRMARKET] ❌ Error processing nostr message: {ex}")
+ logger.error(f"[NOSTRMARKET] 📄 Raw message that failed: {msg}")
async def create_or_update_order_from_dm(
@@ -412,28 +431,29 @@ async def extract_customer_order_from_dm(
async def _handle_nip04_message(event: NostrEvent):
- 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(
+
+ 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(
event.content, event.tag_values("p")[0]
)
- 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}'")
+ await _handle_outgoing_dms(event, sender_merchant, clear_text_msg)
+ return # IMPORTANT: Return immediately
+
+ # No merchant found in either direction
async def _handle_incoming_dms(
@@ -483,17 +503,18 @@ async def _handle_outgoing_dms(
async def _handle_incoming_structured_dm(
merchant: Merchant, dm: DirectMessage, json_data: dict
-) -> tuple[DirectMessageType, str | None]:
+) -> Tuple[DirectMessageType, Optional[str]]:
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.warning(ex)
+ logger.error(f"[NOSTRMARKET] Error in _handle_incoming_structured_dm: {ex}")
return DirectMessageType.PLAIN_TEXT, None
@@ -574,9 +595,31 @@ 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.debug(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)}")
payment_req = await create_new_failed_order(
merchant_id,
merchant_public_key,
@@ -584,12 +627,17 @@ async def _handle_new_order(
json_data,
"Order received, but cannot be processed. Please contact merchant.",
)
- assert payment_req
+
+ if not payment_req:
+ logger.error(f"[NOSTRMARKET] No payment request returned for order: {partial_order.id}")
+ return ""
+
response = {
"type": DirectMessageType.PAYMENT_REQUEST.value,
**payment_req.dict(),
}
- return json.dumps(response, separators=(",", ":"), ensure_ascii=False)
+ response_json = json.dumps(response, separators=(",", ":"), ensure_ascii=False)
+ return response_json
async def create_new_failed_order(
@@ -622,8 +670,11 @@ 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, last_dm_time, last_stall_time, last_prod_time, 0
+ public_keys, lenient_dm_time, last_stall_time, last_prod_time, 0
)
diff --git a/static/components/edit-profile-dialog.js b/static/components/edit-profile-dialog.js
new file mode 100644
index 0000000..0736695
--- /dev/null
+++ b/static/components/edit-profile-dialog.js
@@ -0,0 +1,91 @@
+window.app.component('edit-profile-dialog', {
+ name: 'edit-profile-dialog',
+ template: '#edit-profile-dialog',
+ delimiters: ['${', '}'],
+ props: ['model-value', 'merchant-id', 'merchant-config', 'adminkey'],
+ emits: ['update:model-value', 'profile-updated'],
+ data: function () {
+ return {
+ saving: false,
+ formData: {
+ name: '',
+ display_name: '',
+ about: '',
+ picture: '',
+ banner: '',
+ website: '',
+ nip05: '',
+ lud16: ''
+ }
+ }
+ },
+ computed: {
+ show: {
+ get() {
+ return this.modelValue
+ },
+ set(value) {
+ this.$emit('update:model-value', value)
+ }
+ }
+ },
+ methods: {
+ saveProfile: async function () {
+ this.saving = true
+ try {
+ const config = {
+ ...this.merchantConfig,
+ name: this.formData.name || null,
+ display_name: this.formData.display_name || null,
+ about: this.formData.about || null,
+ picture: this.formData.picture || null,
+ banner: this.formData.banner || null,
+ website: this.formData.website || null,
+ nip05: this.formData.nip05 || null,
+ lud16: this.formData.lud16 || null
+ }
+ await LNbits.api.request(
+ 'PATCH',
+ `/nostrmarket/api/v1/merchant/${this.merchantId}`,
+ this.adminkey,
+ config
+ )
+ // Publish to Nostr
+ await LNbits.api.request(
+ 'PUT',
+ `/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
+ this.adminkey
+ )
+ this.show = false
+ this.$q.notify({
+ type: 'positive',
+ message: 'Profile saved and published to Nostr!'
+ })
+ this.$emit('profile-updated')
+ } catch (error) {
+ LNbits.utils.notifyApiError(error)
+ } finally {
+ this.saving = false
+ }
+ },
+ loadFormData: function () {
+ if (this.merchantConfig) {
+ this.formData.name = this.merchantConfig.name || ''
+ this.formData.display_name = this.merchantConfig.display_name || ''
+ this.formData.about = this.merchantConfig.about || ''
+ this.formData.picture = this.merchantConfig.picture || ''
+ this.formData.banner = this.merchantConfig.banner || ''
+ this.formData.website = this.merchantConfig.website || ''
+ this.formData.nip05 = this.merchantConfig.nip05 || ''
+ this.formData.lud16 = this.merchantConfig.lud16 || ''
+ }
+ }
+ },
+ watch: {
+ modelValue(newVal) {
+ if (newVal) {
+ this.loadFormData()
+ }
+ }
+ }
+})
diff --git a/static/components/key-pair.js b/static/components/key-pair.js
deleted file mode 100644
index 5bf9d23..0000000
--- a/static/components/key-pair.js
+++ /dev/null
@@ -1,22 +0,0 @@
-window.app.component('key-pair', {
- name: 'key-pair',
- template: '#key-pair',
- delimiters: ['${', '}'],
- props: ['public-key', 'private-key'],
- data: function () {
- return {
- showPrivateKey: false
- }
- },
- methods: {
- copyText: function (text, message, position) {
- var notify = this.$q.notify
- Quasar.copyToClipboard(text).then(function () {
- notify({
- message: message || 'Copied to clipboard!',
- position: position || 'bottom'
- })
- })
- }
- }
-})
diff --git a/static/components/merchant-tab.js b/static/components/merchant-tab.js
index c82d853..1194420 100644
--- a/static/components/merchant-tab.js
+++ b/static/components/merchant-tab.js
@@ -10,14 +10,46 @@ window.app.component('merchant-tab', {
'merchant-active',
'public-key',
'private-key',
- 'is-admin'
+ 'is-admin',
+ 'merchant-config'
],
+ emits: [
+ 'toggle-show-keys',
+ 'hide-keys',
+ 'merchant-deleted',
+ 'toggle-merchant-state',
+ 'restart-nostr-connection',
+ 'profile-updated',
+ 'import-key',
+ 'generate-key'
+ ],
+ data: function () {
+ return {
+ showEditProfileDialog: false,
+ showKeysDialog: false
+ }
+ },
computed: {
marketClientUrl: function () {
return '/nostrmarket/market'
}
},
methods: {
+ publishProfile: async function () {
+ try {
+ await LNbits.api.request(
+ 'PUT',
+ `/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
+ this.adminkey
+ )
+ this.$q.notify({
+ type: 'positive',
+ message: 'Profile published to Nostr!'
+ })
+ } catch (error) {
+ LNbits.utils.notifyApiError(error)
+ }
+ },
toggleShowKeys: function () {
this.$emit('toggle-show-keys')
},
@@ -27,11 +59,40 @@ window.app.component('merchant-tab', {
handleMerchantDeleted: function () {
this.$emit('merchant-deleted')
},
+ removeMerchant: function () {
+ const name =
+ this.merchantConfig?.display_name ||
+ this.merchantConfig?.name ||
+ 'this merchant'
+ LNbits.utils
+ .confirmDialog(
+ `Are you sure you want to remove "${name}"? This will delete all associated data (stalls, products, orders, messages).`
+ )
+ .onOk(async () => {
+ try {
+ await LNbits.api.request(
+ 'DELETE',
+ `/nostrmarket/api/v1/merchant/${this.merchantId}`,
+ this.adminkey
+ )
+ this.$emit('merchant-deleted')
+ this.$q.notify({
+ type: 'positive',
+ message: 'Merchant removed'
+ })
+ } catch (error) {
+ LNbits.utils.notifyApiError(error)
+ }
+ })
+ },
toggleMerchantState: function () {
this.$emit('toggle-merchant-state')
},
restartNostrConnection: function () {
this.$emit('restart-nostr-connection')
+ },
+ handleImageError: function (e) {
+ e.target.style.display = 'none'
}
}
})
diff --git a/static/components/nostr-keys-dialog.js b/static/components/nostr-keys-dialog.js
new file mode 100644
index 0000000..81c451f
--- /dev/null
+++ b/static/components/nostr-keys-dialog.js
@@ -0,0 +1,56 @@
+window.app.component('nostr-keys-dialog', {
+ name: 'nostr-keys-dialog',
+ template: '#nostr-keys-dialog',
+ delimiters: ['${', '}'],
+ props: ['public-key', 'private-key', 'model-value'],
+ emits: ['update:model-value'],
+ data: function () {
+ return {
+ showNsec: false
+ }
+ },
+ computed: {
+ show: {
+ get() {
+ return this.modelValue
+ },
+ set(value) {
+ this.$emit('update:model-value', value)
+ }
+ },
+ npub: function () {
+ if (!this.publicKey) return ''
+ try {
+ return window.NostrTools.nip19.npubEncode(this.publicKey)
+ } catch (e) {
+ return this.publicKey
+ }
+ },
+ nsec: function () {
+ if (!this.privateKey) return ''
+ try {
+ return window.NostrTools.nip19.nsecEncode(this.privateKey)
+ } catch (e) {
+ return this.privateKey
+ }
+ }
+ },
+ methods: {
+ copyText: function (text, message) {
+ var notify = this.$q.notify
+ Quasar.copyToClipboard(text).then(function () {
+ notify({
+ message: message || 'Copied to clipboard!',
+ position: 'bottom'
+ })
+ })
+ }
+ },
+ watch: {
+ modelValue(newVal) {
+ if (!newVal) {
+ this.showNsec = false
+ }
+ }
+ }
+})
diff --git a/static/js/index.js b/static/js/index.js
index 00560df..b10220c 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -5,7 +5,7 @@ window.app = Vue.createApp({
mixins: [window.windowMixin],
data: function () {
return {
- activeTab: 'merchant',
+ activeTab: 'orders',
selectedStallFilter: null,
merchant: {},
shippingZones: [],
@@ -19,6 +19,13 @@ window.app = Vue.createApp({
privateKey: null
}
},
+ generateKeyDialog: {
+ show: false,
+ privateKey: null,
+ nsec: null,
+ npub: null,
+ showNsec: false
+ },
wsConnection: null,
nostrStatus: {
connected: false,
@@ -43,26 +50,18 @@ window.app = Vue.createApp({
},
methods: {
generateKeys: async function () {
- const privateKey = nostr.generatePrivateKey()
- await this.createMerchant(privateKey)
+ // No longer need to generate keys here - the backend will use user's existing keypairs
+ await this.createMerchant()
},
importKeys: async function () {
this.importKeyDialog.show = false
- 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)
+ // 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
+ })
},
showImportKeysDialog: async function () {
this.importKeyDialog.show = true
@@ -117,12 +116,9 @@ window.app = Vue.createApp({
this.showKeys = false
this.stallCount = 0
},
- createMerchant: async function (privateKey) {
+ createMerchant: async function () {
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 013a281..774951f 100644
--- a/tasks.py
+++ b/tasks.py
@@ -35,13 +35,17 @@ 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"Subcription failed. Will retry in one minute: {e}")
+ logger.warning(f"[NOSTRMARKET DEBUG] Subscription failed. Will retry in 10 seconds: {e}")
await asyncio.sleep(10)
diff --git a/templates/nostrmarket/components/edit-profile-dialog.html b/templates/nostrmarket/components/edit-profile-dialog.html
new file mode 100644
index 0000000..a447237
--- /dev/null
+++ b/templates/nostrmarket/components/edit-profile-dialog.html
@@ -0,0 +1,68 @@
+