diff --git a/README.md b/README.md
index 1839351..daa0daf 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,13 @@
+
+
+
+
+
+
+
+[](./LICENSE)
+[](https://github.com/lnbits/lnbits)
+
# Nostr Market ([NIP-15](https://github.com/nostr-protocol/nips/blob/master/15.md)) - [LNbits](https://github.com/lnbits/lnbits) extension
For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions).
@@ -147,3 +157,10 @@ Stall and product are _Parameterized Replaceable Events_ according to [NIP-33](h
Order placing, invoicing, payment details and order statuses are handled over Nostr using [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md).
Customer support is handled over whatever communication method was specified. If communicationg via nostr, [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) is used.
+
+## Powered by LNbits
+
+[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
+
+[](https://shop.lnbits.com/)
+[](https://my.lnbits.com/login)
diff --git a/config.json b/config.json
index 1aa34a8..3a670de 100644
--- a/config.json
+++ b/config.json
@@ -1,12 +1,15 @@
{
- "name": "Nostr Market",
+ "id": "nostrmarket",
"version": "1.1.0",
+ "name": "Nostr Market",
+ "repo": "https://github.com/lnbits/nostrmarket",
"short_description": "Nostr Webshop/market on LNbits",
- "tile": "/nostrmarket/static/images/nostr-market.png",
+ "description": "",
+ "tile": "/nostrmarket/static/images/bitcoin-shop.png",
"min_lnbits_version": "1.4.0",
"contributors": [
{
- "name": "motorina0",
+ "name": "Vlad Stan",
"uri": "https://github.com/motorina0",
"role": "Contributor"
},
@@ -19,6 +22,11 @@
"name": "talvasconcelos",
"uri": "https://github.com/talvasconcelos",
"role": "Developer"
+ },
+ {
+ "name": "BenGWeeks",
+ "uri": "https://github.com/BenGWeeks",
+ "role": "Developer"
}
],
"images": [
@@ -44,5 +52,9 @@
],
"description_md": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/description.md",
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/toc.md",
- "license": "MIT"
+ "license": "MIT",
+ "paid_features": "",
+ "tags": ["Nostr", "Marketplace"],
+ "donate": "",
+ "hidden": false
}
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/description.md b/description.md
index 6446ca7..3cfe8bc 100644
--- a/description.md
+++ b/description.md
@@ -1,12 +1,10 @@
-> IMPORTANT: Nostr market needs the nostr-client extension installed.
+Buy and sell products over Nostr using the NIP-15 marketplace protocol.
-Buy and sell things over Nostr, using NIP15 https://github.com/nostr-protocol/nips/blob/master/15.md
+Its functions include:
-Nostr was partly based on the the previous version of this extension "Diagon Alley", so lends itself very well to buying and sellinng over Nostr.
+- Managing products, sales, and customer communication as a merchant
+- Browsing and ordering products as a customer
+- Tracking order status and delivery
+- Communicating via NIP-04 encrypted direct messages
-The Nostr Market extension includes:
-
-- A merchant client to manage products, sales and communication with customers.
-- A customer client to find and order products from merchants, communicate with merchants and track status of ordered products.
-
-All communication happens over NIP04 encrypted DMs.
+A decentralized commerce solution for merchants and buyers who want to trade goods and services over Nostr with end-to-end encrypted communication.
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 58842d5..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,21 +32,26 @@ class Nostrable:
class MerchantProfile(BaseModel):
- name: str | None = None
- display_name: str | None = None
- about: str | None = None
- picture: str | None = None
- banner: str | None = None
- website: str | None = None
- nip05: str | None = None
- lud16: str | None = None
+ 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):
@@ -57,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_)
@@ -139,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":
@@ -156,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:
@@ -229,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
@@ -249,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 = {
@@ -307,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":
@@ -324,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
@@ -354,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
@@ -387,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(
@@ -417,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]"
@@ -466,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
@@ -480,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):
@@ -497,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 #######################################
@@ -514,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:
@@ -546,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 b978e39..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)
@@ -220,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
@@ -288,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(
@@ -313,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:
@@ -326,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(
@@ -411,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(
@@ -482,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
@@ -573,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,
@@ -583,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(
@@ -621,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/merchant-tab.js b/static/components/merchant-tab.js
index 1194420..9595c86 100644
--- a/static/components/merchant-tab.js
+++ b/static/components/merchant-tab.js
@@ -31,7 +31,13 @@ window.app.component('merchant-tab', {
},
computed: {
marketClientUrl: function () {
- return '/nostrmarket/market'
+ if (!this.publicKey) {
+ return '/nostrmarket/market'
+ }
+
+ const url = new URL('/nostrmarket/market', window.location.origin)
+ url.searchParams.set('merchant', this.publicKey)
+ return url.pathname + url.search
}
},
methods: {
diff --git a/static/js/index.js b/static/js/index.js
index f14bf1d..b10220c 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -49,46 +49,19 @@ window.app = Vue.createApp({
}
},
methods: {
- generateKeys: function () {
- const privateKey = nostr.generatePrivateKey()
- const publicKey = nostr.getPublicKey(privateKey)
- this.generateKeyDialog.privateKey = privateKey
- this.generateKeyDialog.nsec = nostr.nip19.nsecEncode(privateKey)
- this.generateKeyDialog.npub = nostr.nip19.npubEncode(publicKey)
- this.generateKeyDialog.showNsec = false
- this.generateKeyDialog.show = true
- },
- confirmGenerateKey: async function () {
- this.generateKeyDialog.show = false
- await this.createMerchant(this.generateKeyDialog.privateKey)
+ generateKeys: async function () {
+ // 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
- }
- // Check if this key is already in use
- const publicKey = nostr.getPublicKey(privateKey)
- if (this.merchant?.public_key === publicKey) {
- this.$q.notify({
- type: 'warning',
- message: 'This key is already your current profile'
- })
- return
- }
- } catch (error) {
- this.$q.notify({
- type: 'negative',
- message: `${error}`
- })
- return
- }
- await this.createMerchant(privateKey)
+ // 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
@@ -143,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/static/market/js/utils.js b/static/market/js/utils.js
index 2e41b49..8a3a98b 100644
--- a/static/market/js/utils.js
+++ b/static/market/js/utils.js
@@ -1,5 +1,43 @@
var NostrTools = window.NostrTools
+;(function ensureRandomUUID() {
+ if (!globalThis.crypto) {
+ globalThis.crypto = {}
+ }
+ if (!globalThis.crypto.randomUUID) {
+ globalThis.crypto.randomUUID = function () {
+ const getRandomValues = globalThis.crypto.getRandomValues
+ if (getRandomValues) {
+ const bytes = new Uint8Array(16)
+ getRandomValues.call(globalThis.crypto, bytes)
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
+ const hex = Array.from(bytes, b =>
+ b.toString(16).padStart(2, '0')
+ ).join('')
+ return (
+ hex.slice(0, 8) +
+ '-' +
+ hex.slice(8, 12) +
+ '-' +
+ hex.slice(12, 16) +
+ '-' +
+ hex.slice(16, 20) +
+ '-' +
+ hex.slice(20)
+ )
+ }
+
+ let d = Date.now()
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
+ const r = (d + Math.random() * 16) % 16 | 0
+ d = Math.floor(d / 16)
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
+ })
+ }
+ }
+})()
+
var defaultRelays = [
'wss://relay.damus.io',
'wss://relay.snort.social',
@@ -44,13 +82,24 @@ function confirm(message) {
async function hash(string) {
- const utf8 = new TextEncoder().encode(string)
- const hashBuffer = await crypto.subtle.digest('SHA-256', utf8)
- const hashArray = Array.from(new Uint8Array(hashBuffer))
- const hashHex = hashArray
- .map(bytes => bytes.toString(16).padStart(2, '0'))
- .join('')
- return hashHex
+ const subtle = globalThis.crypto && globalThis.crypto.subtle
+ if (subtle && subtle.digest) {
+ const utf8 = new TextEncoder().encode(string)
+ const hashBuffer = await subtle.digest('SHA-256', utf8)
+ const hashArray = Array.from(new Uint8Array(hashBuffer))
+ return hashArray.map(bytes => bytes.toString(16).padStart(2, '0')).join('')
+ }
+
+ // Fallback for non-secure contexts where crypto.subtle is unavailable.
+ return fallbackHash(string)
+}
+
+function fallbackHash(string) {
+ let hash = 5381
+ for (let i = 0; i < string.length; i++) {
+ hash = ((hash << 5) + hash) + string.charCodeAt(i)
+ }
+ return (hash >>> 0).toString(16).padStart(8, '0')
}
function isJson(str) {
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/merchant-tab.html b/templates/nostrmarket/components/merchant-tab.html
index 3d932d2..8ce6624 100644
--- a/templates/nostrmarket/components/merchant-tab.html
+++ b/templates/nostrmarket/components/merchant-tab.html
@@ -9,29 +9,26 @@
- Edit
-
- Show Keys
-
+
+
+
+
+
+ View Keys
+ Show public/private keys
+
+
+
Saved Profiles
@@ -88,6 +85,13 @@
+
+
diff --git a/views_api.py b/views_api.py
index 1e9f5c5..7bf3574 100644
--- a/views_api.py
+++ b/views_api.py
@@ -1,15 +1,18 @@
import json
from http import HTTPStatus
+from typing import List, Optional
from fastapi import Depends
from fastapi.exceptions import HTTPException
-from lnbits.core.models import WalletTypeInfo
+from lnbits.core.crud import get_account, update_account
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
@@ -58,6 +61,7 @@ from .crud import (
)
from .helpers import normalize_public_key
from .models import (
+ CreateMerchantRequest,
Customer,
DirectMessage,
DirectMessageType,
@@ -90,15 +94,48 @@ from .services import (
@nostrmarket_ext.post("/api/v1/merchant")
async def api_create_merchant(
- data: PartialMerchant,
+ data: CreateMerchantRequest,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Merchant:
try:
- merchant = await get_merchant_by_pubkey(data.public_key)
- assert merchant is None, "A merchant already uses this public key"
+ # Check if merchant already exists for this user
+ merchant = await get_merchant_for_user(wallet.wallet.user)
+ assert merchant is None, "A merchant already exists for this user"
- merchant = await create_merchant(wallet.wallet.user, data)
+ # 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)
await create_zone(
merchant.id,
@@ -113,7 +150,7 @@ async def api_create_merchant(
await resubscribe_to_all_merchants()
- await nostr_client.merchant_temp_subscription(data.public_key)
+ await nostr_client.merchant_temp_subscription(public_key)
return merchant
except AssertionError as ex:
@@ -132,7 +169,7 @@ async def api_create_merchant(
@nostrmarket_ext.get("/api/v1/merchant")
async def api_get_merchant(
wallet: WalletTypeInfo = Depends(require_invoice_key),
-) -> Merchant | None:
+) -> Optional[Merchant]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
@@ -329,7 +366,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"
@@ -529,7 +566,7 @@ async def api_get_stall(
@nostrmarket_ext.get("/api/v1/stall")
async def api_get_stalls(
- pending: bool | None = False,
+ pending: Optional[bool] = False,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
@@ -553,7 +590,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: bool | None = False,
+ pending: Optional[bool] = False,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
@@ -577,9 +614,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: bool | None = None,
- shipped: bool | None = None,
- pubkey: str | None = None,
+ paid: Optional[bool] = None,
+ shipped: Optional[bool] = None,
+ pubkey: Optional[str] = None,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
@@ -713,7 +750,7 @@ async def api_update_product(
async def api_get_product(
product_id: str,
wallet: WalletTypeInfo = Depends(require_invoice_key),
-) -> Product | None:
+) -> Optional[Product]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
@@ -798,9 +835,9 @@ async def api_get_order(
@nostrmarket_ext.get("/api/v1/order")
async def api_get_orders(
- paid: bool | None = None,
- shipped: bool | None = None,
- pubkey: str | None = None,
+ paid: Optional[bool] = None,
+ shipped: Optional[bool] = None,
+ pubkey: Optional[str] = None,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
@@ -886,7 +923,7 @@ async def api_update_order_status(
async def api_restore_order(
event_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
-) -> Order | None:
+) -> Optional[Order]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
@@ -1013,7 +1050,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"
@@ -1069,7 +1106,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"