Compare commits

..

No commits in common. "f85cbaa65e45e259a5f5b6f354b406e80fc4fc91" and "1a6d1aed104df7c588a890d830ed9a7aee8c5759" have entirely different histories.

18 changed files with 372 additions and 1377 deletions

69
crud.py
View file

@ -1,5 +1,4 @@
import json import json
from typing import List, Optional, Tuple
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
@ -44,7 +43,7 @@ async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant:
async def update_merchant( async def update_merchant(
user_id: str, merchant_id: str, config: MerchantConfig user_id: str, merchant_id: str, config: MerchantConfig
) -> Optional[Merchant]: ) -> Merchant | None:
await db.execute( await db.execute(
f""" f"""
UPDATE nostrmarket.merchants SET meta = :meta, time = {db.timestamp_now} UPDATE nostrmarket.merchants SET meta = :meta, time = {db.timestamp_now}
@ -55,7 +54,7 @@ async def update_merchant(
return await get_merchant(user_id, merchant_id) return await get_merchant(user_id, merchant_id)
async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None:
await db.execute( await db.execute(
f""" f"""
UPDATE nostrmarket.merchants SET time = {db.timestamp_now} UPDATE nostrmarket.merchants SET time = {db.timestamp_now}
@ -66,7 +65,7 @@ async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
return await get_merchant(user_id, merchant_id) return await get_merchant(user_id, merchant_id)
async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None:
row: dict = await db.fetchone( row: dict = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id AND id = :id""", """SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id AND id = :id""",
{ {
@ -78,7 +77,7 @@ async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
return Merchant.from_row(row) if row else None return Merchant.from_row(row) if row else None
async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]: async def get_merchant_by_pubkey(public_key: str) -> Merchant | None:
row: dict = await db.fetchone( row: dict = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE public_key = :public_key""", """SELECT * FROM nostrmarket.merchants WHERE public_key = :public_key""",
{"public_key": public_key}, {"public_key": public_key},
@ -87,7 +86,7 @@ async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]:
return Merchant.from_row(row) if row else None 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( rows: list[dict] = await db.fetchall(
"""SELECT id, public_key FROM nostrmarket.merchants""", """SELECT id, public_key FROM nostrmarket.merchants""",
) )
@ -95,7 +94,7 @@ async def get_merchants_ids_with_pubkeys() -> List[Tuple[str, str]]:
return [(row["id"], row["public_key"]) for row in rows] return [(row["id"], row["public_key"]) for row in rows]
async def get_merchant_for_user(user_id: str) -> Optional[Merchant]: async def get_merchant_for_user(user_id: str) -> Merchant | None:
row: dict = await db.fetchone( row: dict = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id """, """SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id """,
{"user_id": user_id}, {"user_id": user_id},
@ -138,7 +137,7 @@ async def create_zone(merchant_id: str, data: Zone) -> Zone:
return zone return zone
async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]: async def update_zone(merchant_id: str, z: Zone) -> Zone | None:
await db.execute( await db.execute(
""" """
UPDATE nostrmarket.zones UPDATE nostrmarket.zones
@ -157,7 +156,7 @@ async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]:
return await get_zone(merchant_id, z.id) return await get_zone(merchant_id, z.id)
async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]: async def get_zone(merchant_id: str, zone_id: str) -> Zone | None:
row: dict = await db.fetchone( row: dict = await db.fetchone(
"SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id AND id = :id", "SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id AND id = :id",
{ {
@ -168,7 +167,7 @@ async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]:
return Zone.from_row(row) if row else None 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( rows: list[dict] = await db.fetchall(
"SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id", "SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id",
{"merchant_id": merchant_id}, {"merchant_id": merchant_id},
@ -235,7 +234,7 @@ async def create_stall(merchant_id: str, data: Stall) -> Stall:
return stall return stall
async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]: async def get_stall(merchant_id: str, stall_id: str) -> Stall | None:
row: dict = await db.fetchone( row: dict = await db.fetchone(
""" """
SELECT * FROM nostrmarket.stalls SELECT * FROM nostrmarket.stalls
@ -249,7 +248,7 @@ async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]:
return Stall.from_row(row) if row else None return Stall.from_row(row) if row else None
async def get_stalls(merchant_id: str, pending: Optional[bool] = False) -> List[Stall]: async def get_stalls(merchant_id: str, pending: bool | None = False) -> list[Stall]:
rows: list[dict] = await db.fetchall( rows: list[dict] = await db.fetchall(
""" """
SELECT * FROM nostrmarket.stalls SELECT * FROM nostrmarket.stalls
@ -274,7 +273,7 @@ async def get_last_stall_update_time() -> int:
return row["event_created_at"] or 0 if row else 0 return row["event_created_at"] or 0 if row else 0
async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]: async def update_stall(merchant_id: str, stall: Stall) -> Stall | None:
await db.execute( await db.execute(
""" """
UPDATE nostrmarket.stalls UPDATE nostrmarket.stalls
@ -398,9 +397,7 @@ async def update_product(merchant_id: str, product: Product) -> Product:
return updated_product return updated_product
async def update_product_quantity( async def update_product_quantity(product_id: str, new_quantity: int) -> Product | None:
product_id: str, new_quantity: int
) -> Optional[Product]:
await db.execute( await db.execute(
""" """
UPDATE nostrmarket.products SET quantity = :quantity UPDATE nostrmarket.products SET quantity = :quantity
@ -415,7 +412,7 @@ async def update_product_quantity(
return Product.from_row(row) if row else None return Product.from_row(row) if row else None
async def get_product(merchant_id: str, product_id: str) -> Optional[Product]: async def get_product(merchant_id: str, product_id: str) -> Product | None:
row: dict = await db.fetchone( row: dict = await db.fetchone(
""" """
SELECT * FROM nostrmarket.products SELECT * FROM nostrmarket.products
@ -431,8 +428,8 @@ async def get_product(merchant_id: str, product_id: str) -> Optional[Product]:
async def get_products( async def get_products(
merchant_id: str, stall_id: str, pending: Optional[bool] = False merchant_id: str, stall_id: str, pending: bool | None = False
) -> List[Product]: ) -> list[Product]:
rows: list[dict] = await db.fetchall( rows: list[dict] = await db.fetchall(
""" """
SELECT * FROM nostrmarket.products SELECT * FROM nostrmarket.products
@ -445,8 +442,8 @@ async def get_products(
async def get_products_by_ids( async def get_products_by_ids(
merchant_id: str, product_ids: List[str] merchant_id: str, product_ids: list[str]
) -> List[Product]: ) -> list[Product]:
# todo: revisit # todo: revisit
keys = [] keys = []
@ -467,7 +464,7 @@ async def get_products_by_ids(
return [Product.from_row(row) for row in rows] return [Product.from_row(row) for row in rows]
async def get_wallet_for_product(product_id: str) -> Optional[str]: async def get_wallet_for_product(product_id: str) -> str | None:
row: dict = await db.fetchone( row: dict = await db.fetchone(
""" """
SELECT s.wallet as wallet FROM nostrmarket.products p SELECT s.wallet as wallet FROM nostrmarket.products p
@ -574,7 +571,7 @@ async def create_order(merchant_id: str, o: Order) -> Order:
return order return order
async def get_order(merchant_id: str, order_id: str) -> Optional[Order]: async def get_order(merchant_id: str, order_id: str) -> Order | None:
row: dict = await db.fetchone( row: dict = await db.fetchone(
""" """
SELECT * FROM nostrmarket.orders SELECT * FROM nostrmarket.orders
@ -588,7 +585,7 @@ async def get_order(merchant_id: str, order_id: str) -> Optional[Order]:
return Order.from_row(row) if row else None return Order.from_row(row) if row else None
async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Order]: async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None:
row: dict = await db.fetchone( row: dict = await db.fetchone(
""" """
SELECT * FROM nostrmarket.orders SELECT * FROM nostrmarket.orders
@ -602,7 +599,7 @@ async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Ord
return Order.from_row(row) if row else None 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( q = " AND ".join(
[ [
f"{field[0]} = :{field[0]}" f"{field[0]} = :{field[0]}"
@ -629,7 +626,7 @@ async def get_orders(merchant_id: str, **kwargs) -> List[Order]:
async def get_orders_for_stall( async def get_orders_for_stall(
merchant_id: str, stall_id: str, **kwargs merchant_id: str, stall_id: str, **kwargs
) -> List[Order]: ) -> list[Order]:
q = " AND ".join( q = " AND ".join(
[ [
f"{field[0]} = :{field[0]}" f"{field[0]} = :{field[0]}"
@ -655,7 +652,7 @@ async def get_orders_for_stall(
return [Order.from_row(row) for row in rows] return [Order.from_row(row) for row in rows]
async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Order]: async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | None:
q = ", ".join( q = ", ".join(
[ [
f"{field[0]} = :{field[0]}" f"{field[0]} = :{field[0]}"
@ -679,7 +676,7 @@ async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Or
return await get_order(merchant_id, order_id) return await get_order(merchant_id, order_id)
async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]: async def update_order_paid_status(order_id: str, paid: bool) -> Order | None:
await db.execute( await db.execute(
"UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id", "UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id",
{"paid": paid, "id": order_id}, {"paid": paid, "id": order_id},
@ -693,7 +690,7 @@ async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]
async def update_order_shipped_status( async def update_order_shipped_status(
merchant_id: str, order_id: str, shipped: bool merchant_id: str, order_id: str, shipped: bool
) -> Optional[Order]: ) -> Order | None:
await db.execute( await db.execute(
""" """
UPDATE nostrmarket.orders UPDATE nostrmarket.orders
@ -757,7 +754,7 @@ async def create_direct_message(
return msg return msg
async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMessage]: async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | None:
row: dict = await db.fetchone( row: dict = await db.fetchone(
""" """
SELECT * FROM nostrmarket.direct_messages SELECT * FROM nostrmarket.direct_messages
@ -773,7 +770,7 @@ async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMes
async def get_direct_message_by_event_id( async def get_direct_message_by_event_id(
merchant_id: str, event_id: str merchant_id: str, event_id: str
) -> Optional[DirectMessage]: ) -> DirectMessage | None:
row: dict = await db.fetchone( row: dict = await db.fetchone(
""" """
SELECT * FROM nostrmarket.direct_messages SELECT * FROM nostrmarket.direct_messages
@ -787,7 +784,7 @@ async def get_direct_message_by_event_id(
return DirectMessage.from_row(row) if row else None 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( rows: list[dict] = await db.fetchall(
""" """
SELECT * FROM nostrmarket.direct_messages SELECT * FROM nostrmarket.direct_messages
@ -799,7 +796,7 @@ async def get_direct_messages(merchant_id: str, public_key: str) -> List[DirectM
return [DirectMessage.from_row(row) for row in rows] 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( rows: list[dict] = await db.fetchall(
""" """
SELECT * FROM nostrmarket.direct_messages SELECT * FROM nostrmarket.direct_messages
@ -860,7 +857,7 @@ async def create_customer(merchant_id: str, data: Customer) -> Customer:
return customer return customer
async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]: async def get_customer(merchant_id: str, public_key: str) -> Customer | None:
row: dict = await db.fetchone( row: dict = await db.fetchone(
""" """
SELECT * FROM nostrmarket.customers SELECT * FROM nostrmarket.customers
@ -874,7 +871,7 @@ async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]:
return Customer.from_row(row) if row else None 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( rows: list[dict] = await db.fetchall(
"SELECT * FROM nostrmarket.customers WHERE merchant_id = :merchant_id", "SELECT * FROM nostrmarket.customers WHERE merchant_id = :merchant_id",
{"merchant_id": merchant_id}, {"merchant_id": merchant_id},
@ -882,7 +879,7 @@ async def get_customers(merchant_id: str) -> List[Customer]:
return [Customer.from_row(row) for row in rows] 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 = """ q = """
SELECT public_key, MAX(merchant_id) as merchant_id, MAX(event_created_at) SELECT public_key, MAX(merchant_id) as merchant_id, MAX(event_created_at)
FROM nostrmarket.customers FROM nostrmarket.customers

View file

@ -1,6 +1,5 @@
import base64 import base64
import secrets import secrets
from typing import Optional
import coincurve import coincurve
from bech32 import bech32_decode, convertbits from bech32 import bech32_decode, convertbits
@ -37,7 +36,7 @@ def decrypt_message(encoded_message: str, encryption_key) -> str:
return unpadded_data.decode() return unpadded_data.decode()
def encrypt_message(message: str, encryption_key, iv: Optional[bytes] = None) -> str: def encrypt_message(message: str, encryption_key, iv: bytes | None = None) -> str:
padder = padding.PKCS7(128).padder() padder = padding.PKCS7(128).padder()
padded_data = padder.update(message.encode()) + padder.finalize() padded_data = padder.update(message.encode()) + padder.finalize()

View file

@ -1,320 +0,0 @@
# Nostrmarket Order Discovery Analysis
## Executive Summary
This document analyzes the order discovery mechanism in the Nostrmarket extension and identifies why merchants must manually refresh to see new orders instead of receiving them automatically through persistent subscriptions.
---
## Current Architecture
### Two Subscription Systems
The Nostrmarket extension implements two distinct subscription mechanisms for receiving Nostr events:
#### 1. **Persistent Subscriptions (Background Task)**
**Purpose**: Continuous monitoring for new orders, products, and merchant events
**Implementation**:
- Runs via `wait_for_nostr_events()` background task
- Initiated on extension startup (15-second delay)
- Creates subscription ID: `nostrmarket-{hash}`
- Monitors all merchant public keys continuously
**Code Location**: `/nostrmarket/tasks.py:37-49`
```python
async def wait_for_nostr_events(nostr_client: NostrClient):
while True:
try:
await subscribe_to_all_merchants()
while True:
message = await nostr_client.get_event()
await process_nostr_message(message)
```
**Subscription Filters**:
- Direct messages (kind 4) - for orders
- Stall events (kind 30017)
- Product events (kind 30018)
- Profile updates (kind 0)
#### 2. **Temporary Subscriptions (Manual Refresh)**
**Purpose**: Catch up on missed events when merchant clicks "Refresh from Nostr"
**Implementation**:
- Duration: 10 seconds only
- Triggered by user action
- Creates subscription ID: `merchant-{hash}`
- Fetches ALL events from time=0
**Code Location**: `/nostrmarket/nostr/nostr_client.py:100-120`
```python
async def merchant_temp_subscription(self, pk, duration=10):
dm_filters = self._filters_for_direct_messages([pk], 0)
# ... creates filters with time=0 (all history)
await self.send_req_queue.put(["REQ", subscription_id] + merchant_filters)
asyncio.create_task(unsubscribe_with_delay(subscription_id, duration))
```
---
## Problem Identification
### Why Manual Refresh is Required
#### **Issue 1: Timing Window Problem**
The persistent subscription uses timestamps from the last database update:
```python
async def subscribe_to_all_merchants():
last_dm_time = await get_last_direct_messages_created_at()
last_stall_time = await get_last_stall_update_time()
last_prod_time = await get_last_product_update_time()
await nostr_client.subscribe_merchants(
public_keys, last_dm_time, last_stall_time, last_prod_time, 0
)
```
**Problem**: Events that occur between:
- The last database update time
- When the subscription becomes active
...are potentially missed
#### **Issue 2: Connection Stability**
The WebSocket connection between components may be unstable:
```
[Nostrmarket] <--WebSocket--> [Nostrclient] <--WebSocket--> [Nostr Relays]
Extension Extension (Global)
```
**Potential failure points**:
1. Connection drops between nostrmarket → nostrclient
2. Connection drops between nostrclient → relays
3. Reconnection doesn't re-establish subscriptions
#### **Issue 3: Subscription State Management**
**Current behavior**:
- Single persistent subscription per merchant
- No automatic resubscription on failure
- No heartbeat/keepalive mechanism
- No verification that subscription is active
#### **Issue 4: Event Processing Delays**
The startup sequence has intentional delays:
```python
async def _subscribe_to_nostr_client():
await asyncio.sleep(10) # Wait for nostrclient
await nostr_client.run_forever()
async def _wait_for_nostr_events():
await asyncio.sleep(15) # Wait for extension init
await wait_for_nostr_events(nostr_client)
```
**Problem**: Orders arriving during initialization are missed
---
## Why Manual Refresh Works
The temporary subscription succeeds because:
1. **Fetches from time=0**: Gets ALL historical events
2. **Fresh connection**: Creates new subscription request
3. **Immediate processing**: No startup delays
4. **Direct feedback**: User sees results immediately
```python
# Temporary subscription uses time=0 (all events)
dm_filters = self._filters_for_direct_messages([pk], 0) # ← 0 means all time
# Persistent subscription uses last update time
dm_filters = self._filters_for_direct_messages(public_keys, dm_time) # ← can miss events
```
---
## Impact Analysis
### User Experience Issues
1. **Merchants miss orders** without manual refresh
2. **No real-time notifications** for new orders
3. **Uncertainty** about order status
4. **Extra manual steps** required
5. **Delayed order fulfillment**
### Technical Implications
1. **Not truly decentralized** - requires active monitoring
2. **Scalability concerns** - manual refresh doesn't scale
3. **Reliability issues** - depends on user action
4. **Performance overhead** - fetching all events repeatedly
---
## Recommended Solutions
### Solution A: Enhanced Persistent Subscriptions
**Implement redundant subscription mechanisms:**
```python
class EnhancedSubscriptionManager:
def __init__(self):
self.last_heartbeat = time.time()
self.subscription_active = False
async def maintain_subscription(self):
while True:
if not self.subscription_active or \
time.time() - self.last_heartbeat > 30:
await self.resubscribe_with_overlap()
await asyncio.sleep(10)
async def resubscribe_with_overlap(self):
# Use timestamp with 5-minute overlap
overlap_time = int(time.time()) - 300
await subscribe_to_all_merchants(since=overlap_time)
```
### Solution B: Periodic Auto-Refresh
**Add automatic temporary subscriptions:**
```python
async def auto_refresh_loop():
while True:
await asyncio.sleep(60) # Every minute
merchants = await get_all_active_merchants()
for merchant in merchants:
await merchant_temp_subscription(merchant.pubkey, duration=5)
```
### Solution C: WebSocket Health Monitoring
**Implement connection health checks:**
```python
class WebSocketHealthMonitor:
async def check_connection_health(self):
try:
# Send ping to nostrclient
response = await nostr_client.ping(timeout=5)
if not response:
await self.reconnect_and_resubscribe()
except Exception:
await self.reconnect_and_resubscribe()
```
### Solution D: Event Gap Detection
**Detect and fill gaps in event sequence:**
```python
async def detect_event_gaps():
# Check for gaps in event timestamps
last_known = await get_last_event_time()
current_time = int(time.time())
if current_time - last_known > 60: # 1 minute gap
# Perform temporary subscription to fill gap
await fetch_missing_events(since=last_known)
```
---
## Implementation Priority
### Phase 1: Quick Fixes (1-2 days)
1. [DONE] Increase temp subscription duration (10s → 30s)
2. [DONE] Add connection health logging
3. [DONE] Reduce startup delays
### Phase 2: Reliability (3-5 days)
1. [TODO] Implement subscription heartbeat
2. [TODO] Add automatic resubscription on failure
3. [TODO] Create event gap detection
### Phase 3: Full Solution (1-2 weeks)
1. [TODO] WebSocket connection monitoring
2. [TODO] Redundant subscription system
3. [TODO] Real-time order notifications
4. [TODO] Event deduplication logic
---
## Testing Recommendations
### Test Scenarios
1. **Order during startup**: Send order within 15 seconds of server start
2. **Long-running test**: Keep server running for 24 hours, send periodic orders
3. **Connection interruption**: Disconnect nostrclient, send order, reconnect
4. **High volume**: Send 100 orders rapidly
5. **Network latency**: Add artificial delay between components
### Monitoring Metrics
- Time between order sent → order discovered
- Percentage of orders requiring manual refresh
- WebSocket connection uptime
- Subscription success rate
- Event processing latency
---
## Conclusion
The current order discovery system relies on manual refresh due to:
1. **Timing gaps** in persistent subscriptions
2. **Connection stability** issues
3. **Lack of redundancy** in subscription management
4. **No automatic recovery** mechanisms
While the temporary subscription (manual refresh) provides a workaround, a proper solution requires implementing connection monitoring, subscription health checks, and automatic gap-filling mechanisms to ensure reliable real-time order discovery.
---
## Appendix: Code References
### Key Files
- `/nostrmarket/tasks.py` - Background task management
- `/nostrmarket/nostr/nostr_client.py` - Nostr client implementation
- `/nostrmarket/services.py` - Order processing logic
- `/nostrmarket/views_api.py` - API endpoints for refresh
### Relevant Functions
- `wait_for_nostr_events()` - Main event loop
- `subscribe_to_all_merchants()` - Persistent subscription
- `merchant_temp_subscription()` - Manual refresh
- `process_nostr_message()` - Event processing
---
_Document prepared: January 2025_
_Analysis based on: Nostrmarket v1.0_
_Status: Active Investigation_

148
models.py
View file

@ -2,7 +2,7 @@ import json
import time import time
from abc import abstractmethod from abc import abstractmethod
from enum import Enum from enum import Enum
from typing import Any, List, Optional, Tuple from typing import Any
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
from pydantic import BaseModel from pydantic import BaseModel
@ -32,26 +32,16 @@ class Nostrable:
class MerchantProfile(BaseModel): class MerchantProfile(BaseModel):
name: Optional[str] = None name: str | None = None
display_name: Optional[str] = None about: str | None = None
about: Optional[str] = None picture: str | None = None
picture: Optional[str] = None
banner: Optional[str] = None
website: Optional[str] = None
nip05: Optional[str] = None
lud16: Optional[str] = None
class MerchantConfig(MerchantProfile): class MerchantConfig(MerchantProfile):
event_id: Optional[str] = None event_id: str | None = None
sync_from_nostr: bool = False sync_from_nostr: bool = False
# TODO: switched to True for AIO demo; determine if we leave this as True active: bool = False
active: bool = True restore_in_progress: bool | None = False
restore_in_progress: Optional[bool] = False
class CreateMerchantRequest(BaseModel):
config: MerchantConfig = MerchantConfig()
class PartialMerchant(BaseModel): class PartialMerchant(BaseModel):
@ -62,7 +52,7 @@ class PartialMerchant(BaseModel):
class Merchant(PartialMerchant, Nostrable): class Merchant(PartialMerchant, Nostrable):
id: str id: str
time: Optional[int] = 0 time: int | None = 0
def sign_hash(self, hash_: bytes) -> str: def sign_hash(self, hash_: bytes) -> str:
return sign_message_hash(self.private_key, hash_) return sign_message_hash(self.private_key, hash_)
@ -96,23 +86,11 @@ class Merchant(PartialMerchant, Nostrable):
return merchant return merchant
def to_nostr_event(self, pubkey: str) -> NostrEvent: def to_nostr_event(self, pubkey: str) -> NostrEvent:
content: dict[str, str] = {} content = {
if self.config.name: "name": self.config.name,
content["name"] = self.config.name "about": self.config.about,
if self.config.display_name: "picture": self.config.picture,
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( event = NostrEvent(
pubkey=pubkey, pubkey=pubkey,
created_at=round(time.time()), created_at=round(time.time()),
@ -144,11 +122,11 @@ class Merchant(PartialMerchant, Nostrable):
######################################## ZONES ######################################## ######################################## ZONES ########################################
class Zone(BaseModel): class Zone(BaseModel):
id: Optional[str] = None id: str | None = None
name: Optional[str] = None name: str | None = None
currency: str currency: str
cost: float cost: float
countries: List[str] = [] countries: list[str] = []
@classmethod @classmethod
def from_row(cls, row: dict) -> "Zone": def from_row(cls, row: dict) -> "Zone":
@ -161,22 +139,22 @@ class Zone(BaseModel):
class StallConfig(BaseModel): class StallConfig(BaseModel):
image_url: Optional[str] = None image_url: str | None = None
description: Optional[str] = None description: str | None = None
class Stall(BaseModel, Nostrable): class Stall(BaseModel, Nostrable):
id: Optional[str] = None id: str | None = None
wallet: str wallet: str
name: str name: str
currency: str = "sat" currency: str = "sat"
shipping_zones: List[Zone] = [] shipping_zones: list[Zone] = []
config: StallConfig = StallConfig() config: StallConfig = StallConfig()
pending: bool = False pending: bool = False
"""Last published nostr event for this Stall""" """Last published nostr event for this Stall"""
event_id: Optional[str] = None event_id: str | None = None
event_created_at: Optional[int] = None event_created_at: int | None = None
def validate_stall(self): def validate_stall(self):
for z in self.shipping_zones: for z in self.shipping_zones:
@ -234,19 +212,19 @@ class ProductShippingCost(BaseModel):
class ProductConfig(BaseModel): class ProductConfig(BaseModel):
description: Optional[str] = None description: str | None = None
currency: Optional[str] = None currency: str | None = None
use_autoreply: Optional[bool] = False use_autoreply: bool | None = False
autoreply_message: Optional[str] = None autoreply_message: str | None = None
shipping: List[ProductShippingCost] = [] shipping: list[ProductShippingCost] = []
class Product(BaseModel, Nostrable): class Product(BaseModel, Nostrable):
id: Optional[str] = None id: str | None = None
stall_id: str stall_id: str
name: str name: str
categories: List[str] = [] categories: list[str] = []
images: List[str] = [] images: list[str] = []
price: float price: float
quantity: int quantity: int
active: bool = True active: bool = True
@ -254,8 +232,8 @@ class Product(BaseModel, Nostrable):
config: ProductConfig = ProductConfig() config: ProductConfig = ProductConfig()
"""Last published nostr event for this Product""" """Last published nostr event for this Product"""
event_id: Optional[str] = None event_id: str | None = None
event_created_at: Optional[int] = None event_created_at: int | None = None
def to_nostr_event(self, pubkey: str) -> NostrEvent: def to_nostr_event(self, pubkey: str) -> NostrEvent:
content = { content = {
@ -312,7 +290,7 @@ class ProductOverview(BaseModel):
id: str id: str
name: str name: str
price: float price: float
product_shipping_cost: Optional[float] = None product_shipping_cost: float | None = None
@classmethod @classmethod
def from_product(cls, p: Product) -> "ProductOverview": def from_product(cls, p: Product) -> "ProductOverview":
@ -329,21 +307,21 @@ class OrderItem(BaseModel):
class OrderContact(BaseModel): class OrderContact(BaseModel):
nostr: Optional[str] = None nostr: str | None = None
phone: Optional[str] = None phone: str | None = None
email: Optional[str] = None email: str | None = None
class OrderExtra(BaseModel): class OrderExtra(BaseModel):
products: List[ProductOverview] products: list[ProductOverview]
currency: str currency: str
btc_price: str btc_price: str
shipping_cost: float = 0 shipping_cost: float = 0
shipping_cost_sat: float = 0 shipping_cost_sat: float = 0
fail_message: Optional[str] = None fail_message: str | None = None
@classmethod @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" currency = products[0].config.currency if len(products) else "sat"
exchange_rate = ( exchange_rate = (
await btc_price(currency) if currency and currency != "sat" else 1 await btc_price(currency) if currency and currency != "sat" else 1
@ -359,19 +337,19 @@ class OrderExtra(BaseModel):
class PartialOrder(BaseModel): class PartialOrder(BaseModel):
id: str id: str
event_id: Optional[str] = None event_id: str | None = None
event_created_at: Optional[int] = None event_created_at: int | None = None
public_key: str public_key: str
merchant_public_key: str merchant_public_key: str
shipping_id: str shipping_id: str
items: List[OrderItem] items: list[OrderItem]
contact: Optional[OrderContact] = None contact: OrderContact | None = None
address: Optional[str] = None address: str | None = None
def validate_order(self): def validate_order(self):
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'" 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(self.items) != 0, f"Order has no items. Order: '{self.id}'"
assert ( assert (
len(product_list) != 0 len(product_list) != 0
@ -392,8 +370,8 @@ class PartialOrder(BaseModel):
) )
async def costs_in_sats( async def costs_in_sats(
self, products: List[Product], shipping_id: str, stall_shipping_cost: float self, products: list[Product], shipping_id: str, stall_shipping_cost: float
) -> Tuple[float, float]: ) -> tuple[float, float]:
product_prices = {} product_prices = {}
for p in products: for p in products:
product_shipping_cost = next( product_shipping_cost = next(
@ -422,7 +400,7 @@ class PartialOrder(BaseModel):
return product_cost, stall_shipping_cost return product_cost, stall_shipping_cost
def receipt( 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: ) -> str:
if len(products) == 0: if len(products) == 0:
return "[No Products]" return "[No Products]"
@ -471,7 +449,7 @@ class Order(PartialOrder):
total: float total: float
paid: bool = False paid: bool = False
shipped: bool = False shipped: bool = False
time: Optional[int] = None time: int | None = None
extra: OrderExtra extra: OrderExtra
@classmethod @classmethod
@ -485,14 +463,14 @@ class Order(PartialOrder):
class OrderStatusUpdate(BaseModel): class OrderStatusUpdate(BaseModel):
id: str id: str
message: Optional[str] = None message: str | None = None
paid: Optional[bool] = False paid: bool | None = False
shipped: Optional[bool] = None shipped: bool | None = None
class OrderReissue(BaseModel): class OrderReissue(BaseModel):
id: str id: str
shipping_id: Optional[str] = None shipping_id: str | None = None
class PaymentOption(BaseModel): class PaymentOption(BaseModel):
@ -502,8 +480,8 @@ class PaymentOption(BaseModel):
class PaymentRequest(BaseModel): class PaymentRequest(BaseModel):
id: str id: str
message: Optional[str] = None message: str | None = None
payment_options: List[PaymentOption] payment_options: list[PaymentOption]
######################################## MESSAGE ####################################### ######################################## MESSAGE #######################################
@ -519,16 +497,16 @@ class DirectMessageType(Enum):
class PartialDirectMessage(BaseModel): class PartialDirectMessage(BaseModel):
event_id: Optional[str] = None event_id: str | None = None
event_created_at: Optional[int] = None event_created_at: int | None = None
message: str message: str
public_key: str public_key: str
type: int = DirectMessageType.PLAIN_TEXT.value type: int = DirectMessageType.PLAIN_TEXT.value
incoming: bool = False incoming: bool = False
time: Optional[int] = None time: int | None = None
@classmethod @classmethod
def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]: def parse_message(cls, msg) -> tuple[DirectMessageType, Any | None]:
try: try:
msg_json = json.loads(msg) msg_json = json.loads(msg)
if "type" in msg_json: if "type" in msg_json:
@ -551,15 +529,15 @@ class DirectMessage(PartialDirectMessage):
class CustomerProfile(BaseModel): class CustomerProfile(BaseModel):
name: Optional[str] = None name: str | None = None
about: Optional[str] = None about: str | None = None
class Customer(BaseModel): class Customer(BaseModel):
merchant_id: str merchant_id: str
public_key: str public_key: str
event_created_at: Optional[int] = None event_created_at: int | None = None
profile: Optional[CustomerProfile] = None profile: CustomerProfile | None = None
unread_messages: int = 0 unread_messages: int = 0
@classmethod @classmethod

View file

@ -31,11 +31,9 @@ class NostrClient:
logger.debug(f"Connecting to websockets for 'nostrclient' extension...") logger.debug(f"Connecting to websockets for 'nostrclient' extension...")
relay_endpoint = encrypt_internal_message("relay", urlsafe=True) 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() on_open, on_message, on_error, on_close = self._ws_handlers()
ws = WebSocketApp( ws = WebSocketApp(
ws_url, f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}",
on_message=on_message, on_message=on_message,
on_open=on_open, on_open=on_open,
on_close=on_close, on_close=on_close,
@ -67,7 +65,6 @@ class NostrClient:
async def get_event(self): async def get_event(self):
value = await self.recieve_event_queue.get() value = await self.recieve_event_queue.get()
if isinstance(value, ValueError): if isinstance(value, ValueError):
logger.error(f"[NOSTRMARKET] ❌ Queue returned error: {value}")
raise value raise value
return value return value
@ -94,6 +91,10 @@ class NostrClient:
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32] self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
await self.send_req_queue.put(["REQ", self.subscription_id] + merchant_filters) 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): async def merchant_temp_subscription(self, pk, duration=10):
dm_filters = self._filters_for_direct_messages([pk], 0) dm_filters = self._filters_for_direct_messages([pk], 0)
stall_filters = self._filters_for_stall_events([pk], 0) stall_filters = self._filters_for_stall_events([pk], 0)
@ -174,21 +175,16 @@ class NostrClient:
def _ws_handlers(self): def _ws_handlers(self):
def on_open(_): def on_open(_):
logger.debug("[NOSTRMARKET DEBUG] ✅ Connected to 'nostrclient' websocket successfully") logger.info("Connected to 'nostrclient' websocket")
def on_message(_, message): def on_message(_, message):
logger.debug(f"[NOSTRMARKET DEBUG] 📨 Received websocket message: {message[:200]}...") self.recieve_event_queue.put_nowait(message)
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): def on_error(_, error):
logger.warning(f"[NOSTRMARKET] ❌ Websocket error: {error}") logger.warning(error)
def on_close(x, status_code, message): def on_close(x, status_code, message):
logger.warning(f"[NOSTRMARKET] 🔌 Websocket closed: {x}: '{status_code}' '{message}'") logger.warning(f"Websocket closed: {x}: '{status_code}' '{message}'")
# force re-subscribe # force re-subscribe
self.recieve_event_queue.put_nowait(ValueError("Websocket close.")) self.recieve_event_queue.put_nowait(ValueError("Websocket close."))

View file

@ -1,8 +1,7 @@
import asyncio import asyncio
import json import json
from typing import List, Optional, Tuple
from lnbits.bolt11 import decode from bolt11 import decode
from lnbits.core.crud import get_wallet from lnbits.core.crud import get_wallet
from lnbits.core.services import create_invoice, websocket_updater from lnbits.core.services import create_invoice, websocket_updater
from loguru import logger from loguru import logger
@ -60,12 +59,11 @@ from .nostr.event import NostrEvent
async def create_new_order( async def create_new_order(
merchant_public_key: str, data: PartialOrder merchant_public_key: str, data: PartialOrder
) -> Optional[PaymentRequest]: ) -> PaymentRequest | None:
merchant = await get_merchant_by_pubkey(merchant_public_key) merchant = await get_merchant_by_pubkey(merchant_public_key)
assert merchant, "Cannot find merchant for order!" assert merchant, "Cannot find merchant for order!"
existing_order = await get_order(merchant.id, data.id) if await get_order(merchant.id, data.id):
if existing_order:
return None return None
if data.event_id and await get_order_by_event_id(merchant.id, data.event_id): if data.event_id and await get_order_by_event_id(merchant.id, data.event_id):
return None return None
@ -75,24 +73,20 @@ async def create_new_order(
) )
await create_order(merchant.id, order) await create_order(merchant.id, order)
payment_request = PaymentRequest( return PaymentRequest(
id=data.id, id=data.id,
payment_options=[PaymentOption(type="ln", link=invoice)], payment_options=[PaymentOption(type="ln", link=invoice)],
message=receipt, message=receipt,
) )
return payment_request
async def build_order_with_payment( async def build_order_with_payment(
merchant_id: str, merchant_public_key: str, data: PartialOrder merchant_id: str, merchant_public_key: str, data: PartialOrder
): ):
products = await get_products_by_ids( products = await get_products_by_ids(
merchant_id, [p.product_id for p in data.items] merchant_id, [p.product_id for p in data.items]
) )
data.validate_order_items(products) data.validate_order_items(products)
shipping_zone = await get_zone(merchant_id, data.shipping_id) shipping_zone = await get_zone(merchant_id, data.shipping_id)
assert shipping_zone, f"Shipping zone not found for order '{data.id}'" assert shipping_zone, f"Shipping zone not found for order '{data.id}'"
@ -100,7 +94,6 @@ async def build_order_with_payment(
product_cost_sat, shipping_cost_sat = await data.costs_in_sats( product_cost_sat, shipping_cost_sat = await data.costs_in_sats(
products, shipping_zone.id, shipping_zone.cost products, shipping_zone.id, shipping_zone.cost
) )
receipt = data.receipt(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) wallet_id = await get_wallet_for_product(data.items[0].product_id)
@ -111,13 +104,11 @@ async def build_order_with_payment(
merchant_id, product_ids, data.items merchant_id, product_ids, data.items
) )
if not success: if not success:
logger.error(f"[NOSTRMARKET] ❌ Product quantity check failed: {message}")
raise ValueError(message) raise ValueError(message)
total_amount_sat = round(product_cost_sat + shipping_cost_sat)
payment = await create_invoice( payment = await create_invoice(
wallet_id=wallet_id, wallet_id=wallet_id,
amount=total_amount_sat, amount=round(product_cost_sat + shipping_cost_sat),
memo=f"Order '{data.id}' for pubkey '{data.public_key}'", memo=f"Order '{data.id}' for pubkey '{data.public_key}'",
extra={ extra={
"tag": "nostrmarket", "tag": "nostrmarket",
@ -145,7 +136,7 @@ async def update_merchant_to_nostr(
merchant: Merchant, delete_merchant=False merchant: Merchant, delete_merchant=False
) -> Merchant: ) -> Merchant:
stalls = await get_stalls(merchant.id) stalls = await get_stalls(merchant.id)
event: Optional[NostrEvent] = None event: NostrEvent | None = None
for stall in stalls: for stall in stalls:
assert stall.id assert stall.id
products = await get_products(merchant.id, stall.id) products = await get_products(merchant.id, stall.id)
@ -158,8 +149,9 @@ async def update_merchant_to_nostr(
stall.event_id = event.id stall.event_id = event.id
stall.event_created_at = event.created_at stall.event_created_at = event.created_at
await update_stall(merchant.id, stall) await update_stall(merchant.id, stall)
# Always publish merchant profile (kind 0) if delete_merchant:
event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant) # merchant profile updates not supported yet
event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant)
assert event assert event
merchant.config.event_id = event.id merchant.config.event_id = event.id
return merchant return merchant
@ -229,7 +221,7 @@ async def notify_client_of_order_status(
async def update_products_for_order( async def update_products_for_order(
merchant: Merchant, order: Order merchant: Merchant, order: Order
) -> Tuple[bool, str]: ) -> tuple[bool, str]:
product_ids = [i.product_id for i in order.items] product_ids = [i.product_id for i in order.items]
success, products, message = await compute_products_new_quantity( success, products, message = await compute_products_new_quantity(
merchant.id, product_ids, order.items merchant.id, product_ids, order.items
@ -297,9 +289,9 @@ async def send_dm(
async def compute_products_new_quantity( async def compute_products_new_quantity(
merchant_id: str, product_ids: List[str], items: List[OrderItem] merchant_id: str, product_ids: list[str], items: list[OrderItem]
) -> Tuple[bool, List[Product], str]: ) -> tuple[bool, list[Product], str]:
products: List[Product] = await get_products_by_ids(merchant_id, product_ids) products: list[Product] = await get_products_by_ids(merchant_id, product_ids)
for p in products: for p in products:
required_quantity = next( required_quantity = next(
@ -322,17 +314,11 @@ async def compute_products_new_quantity(
async def process_nostr_message(msg: str): async def process_nostr_message(msg: str):
try: try:
parsed_msg = json.loads(msg) type_, *rest = json.loads(msg)
type_, *rest = parsed_msg
if type_.upper() == "EVENT": if type_.upper() == "EVENT":
if len(rest) < 2:
logger.warning(f"[NOSTRMARKET] ⚠️ EVENT message missing data: {rest}")
return
_, event = rest _, event = rest
event = NostrEvent(**event) event = NostrEvent(**event)
if event.kind == 0: if event.kind == 0:
await _handle_customer_profile_update(event) await _handle_customer_profile_update(event)
elif event.kind == 4: elif event.kind == 4:
@ -341,15 +327,10 @@ async def process_nostr_message(msg: str):
await _handle_stall(event) await _handle_stall(event)
elif event.kind == 30018: elif event.kind == 30018:
await _handle_product(event) await _handle_product(event)
else:
logger.info(f"[NOSTRMARKET] ❓ Unhandled event kind: {event.kind} - event: {event.id}")
return return
else:
logger.info(f"[NOSTRMARKET] 🔄 Non-EVENT message type: {type_}")
except Exception as ex: except Exception as ex:
logger.error(f"[NOSTRMARKET] ❌ Error processing nostr message: {ex}") logger.debug(ex)
logger.error(f"[NOSTRMARKET] 📄 Raw message that failed: {msg}")
async def create_or_update_order_from_dm( async def create_or_update_order_from_dm(
@ -431,29 +412,28 @@ async def extract_customer_order_from_dm(
async def _handle_nip04_message(event: NostrEvent): async def _handle_nip04_message(event: NostrEvent):
merchant_public_key = event.pubkey
merchant = await get_merchant_by_pubkey(merchant_public_key)
p_tags = event.tag_values("p") if not 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)
# PRIORITY 1: Check if any recipient (p_tag) is a merchant → incoming message TO merchant assert merchant, f"Merchant not found for public key '{merchant_public_key}'"
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 if event.pubkey == merchant_public_key:
sender_merchant = await get_merchant_by_pubkey(event.pubkey) assert len(event.tag_values("p")) != 0, "Outgong message has no 'p' tag"
if sender_merchant: clear_text_msg = merchant.decrypt_message(
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] event.content, event.tag_values("p")[0]
) )
await _handle_outgoing_dms(event, sender_merchant, clear_text_msg) await _handle_outgoing_dms(event, merchant, clear_text_msg)
return # IMPORTANT: Return immediately elif event.has_tag_value("p", merchant_public_key):
clear_text_msg = merchant.decrypt_message(event.content, event.pubkey)
# No merchant found in either direction await _handle_incoming_dms(event, merchant, clear_text_msg)
else:
logger.warning(f"Bad NIP04 event: '{event.id}'")
async def _handle_incoming_dms( async def _handle_incoming_dms(
@ -503,18 +483,17 @@ async def _handle_outgoing_dms(
async def _handle_incoming_structured_dm( async def _handle_incoming_structured_dm(
merchant: Merchant, dm: DirectMessage, json_data: dict merchant: Merchant, dm: DirectMessage, json_data: dict
) -> Tuple[DirectMessageType, Optional[str]]: ) -> tuple[DirectMessageType, str | None]:
try: try:
if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active: if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active:
json_resp = await _handle_new_order( json_resp = await _handle_new_order(
merchant.id, merchant.public_key, dm, json_data merchant.id, merchant.public_key, dm, json_data
) )
return DirectMessageType.PAYMENT_REQUEST, json_resp 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: except Exception as ex:
logger.error(f"[NOSTRMARKET] Error in _handle_incoming_structured_dm: {ex}") logger.warning(ex)
return DirectMessageType.PLAIN_TEXT, None return DirectMessageType.PLAIN_TEXT, None
@ -595,31 +574,9 @@ async def _handle_new_order(
wallet = await get_wallet(wallet_id) wallet = await get_wallet(wallet_id)
assert wallet, f"Cannot find wallet for product id: {first_product_id}" assert wallet, f"Cannot find wallet for product id: {first_product_id}"
payment_req = await create_new_order(merchant_public_key, partial_order) 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: except Exception as e:
logger.error(f"[NOSTRMARKET] Error creating order: {e}") logger.debug(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( payment_req = await create_new_failed_order(
merchant_id, merchant_id,
merchant_public_key, merchant_public_key,
@ -627,17 +584,12 @@ async def _handle_new_order(
json_data, json_data,
"Order received, but cannot be processed. Please contact merchant.", "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 = { response = {
"type": DirectMessageType.PAYMENT_REQUEST.value, "type": DirectMessageType.PAYMENT_REQUEST.value,
**payment_req.dict(), **payment_req.dict(),
} }
response_json = json.dumps(response, separators=(",", ":"), ensure_ascii=False) return json.dumps(response, separators=(",", ":"), ensure_ascii=False)
return response_json
async def create_new_failed_order( async def create_new_failed_order(
@ -670,11 +622,8 @@ async def subscribe_to_all_merchants():
last_stall_time = await get_last_stall_update_time() last_stall_time = await get_last_stall_update_time()
last_prod_time = await get_last_product_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( await nostr_client.subscribe_merchants(
public_keys, lenient_dm_time, last_stall_time, last_prod_time, 0 public_keys, last_dm_time, last_stall_time, last_prod_time, 0
) )

View file

@ -1,91 +0,0 @@
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()
}
}
}
})

View file

@ -0,0 +1,22 @@
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'
})
})
}
}
})

View file

@ -10,46 +10,14 @@ window.app.component('merchant-tab', {
'merchant-active', 'merchant-active',
'public-key', 'public-key',
'private-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: { computed: {
marketClientUrl: function () { marketClientUrl: function () {
return '/nostrmarket/market' return '/nostrmarket/market'
} }
}, },
methods: { 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 () { toggleShowKeys: function () {
this.$emit('toggle-show-keys') this.$emit('toggle-show-keys')
}, },
@ -59,40 +27,11 @@ window.app.component('merchant-tab', {
handleMerchantDeleted: function () { handleMerchantDeleted: function () {
this.$emit('merchant-deleted') 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 () { toggleMerchantState: function () {
this.$emit('toggle-merchant-state') this.$emit('toggle-merchant-state')
}, },
restartNostrConnection: function () { restartNostrConnection: function () {
this.$emit('restart-nostr-connection') this.$emit('restart-nostr-connection')
},
handleImageError: function (e) {
e.target.style.display = 'none'
} }
} }
}) })

View file

@ -1,56 +0,0 @@
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
}
}
}
})

View file

@ -5,7 +5,7 @@ window.app = Vue.createApp({
mixins: [window.windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
activeTab: 'orders', activeTab: 'merchant',
selectedStallFilter: null, selectedStallFilter: null,
merchant: {}, merchant: {},
shippingZones: [], shippingZones: [],
@ -19,13 +19,6 @@ window.app = Vue.createApp({
privateKey: null privateKey: null
} }
}, },
generateKeyDialog: {
show: false,
privateKey: null,
nsec: null,
npub: null,
showNsec: false
},
wsConnection: null, wsConnection: null,
nostrStatus: { nostrStatus: {
connected: false, connected: false,
@ -50,18 +43,26 @@ window.app = Vue.createApp({
}, },
methods: { methods: {
generateKeys: async function () { generateKeys: async function () {
// No longer need to generate keys here - the backend will use user's existing keypairs const privateKey = nostr.generatePrivateKey()
await this.createMerchant() await this.createMerchant(privateKey)
}, },
importKeys: async function () { importKeys: async function () {
this.importKeyDialog.show = false this.importKeyDialog.show = false
// Import keys functionality removed since we use user's native keypairs let privateKey = this.importKeyDialog.data.privateKey
// Show a message that this is no longer needed if (!privateKey) {
this.$q.notify({ return
type: 'info', }
message: 'Merchants now use your account Nostr keys automatically. Key import is no longer needed.', try {
timeout: 3000 if (privateKey.toLowerCase().startsWith('nsec')) {
}) privateKey = nostr.nip19.decode(privateKey).data
}
} catch (error) {
this.$q.notify({
type: 'negative',
message: `${error}`
})
}
await this.createMerchant(privateKey)
}, },
showImportKeysDialog: async function () { showImportKeysDialog: async function () {
this.importKeyDialog.show = true this.importKeyDialog.show = true
@ -116,9 +117,12 @@ window.app = Vue.createApp({
this.showKeys = false this.showKeys = false
this.stallCount = 0 this.stallCount = 0
}, },
createMerchant: async function () { createMerchant: async function (privateKey) {
try { try {
const pubkey = nostr.getPublicKey(privateKey)
const payload = { const payload = {
private_key: privateKey,
public_key: pubkey,
config: {} config: {}
} }
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(

View file

@ -35,17 +35,13 @@ async def on_invoice_paid(payment: Payment) -> None:
async def wait_for_nostr_events(nostr_client: NostrClient): async def wait_for_nostr_events(nostr_client: NostrClient):
logger.info("[NOSTRMARKET DEBUG] Starting wait_for_nostr_events task")
while True: while True:
try: try:
logger.info("[NOSTRMARKET DEBUG] Subscribing to all merchants...")
await subscribe_to_all_merchants() await subscribe_to_all_merchants()
while True: while True:
logger.debug("[NOSTRMARKET DEBUG] Waiting for nostr event...")
message = await nostr_client.get_event() message = await nostr_client.get_event()
logger.info(f"[NOSTRMARKET DEBUG] Received event from nostr_client: {message[:100]}...")
await process_nostr_message(message) await process_nostr_message(message)
except Exception as e: except Exception as e:
logger.warning(f"[NOSTRMARKET DEBUG] Subscription failed. Will retry in 10 seconds: {e}") logger.warning(f"Subcription failed. Will retry in one minute: {e}")
await asyncio.sleep(10) await asyncio.sleep(10)

View file

@ -1,68 +0,0 @@
<q-dialog v-model="show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="saveProfile" class="q-gutter-md">
<div class="text-h6 q-mb-md">Edit Profile</div>
<q-input
filled
dense
v-model.trim="formData.name"
label="Name (username)"
></q-input>
<q-input
filled
dense
v-model.trim="formData.display_name"
label="Display Name"
></q-input>
<q-input
filled
dense
v-model.trim="formData.about"
label="About"
type="textarea"
rows="3"
></q-input>
<q-input
filled
dense
v-model.trim="formData.picture"
label="Profile Picture URL"
></q-input>
<q-input
filled
dense
v-model.trim="formData.banner"
label="Banner Image URL"
></q-input>
<q-input
filled
dense
v-model.trim="formData.website"
label="Website"
></q-input>
<q-input
filled
dense
v-model.trim="formData.nip05"
label="NIP-05 Identifier"
></q-input>
<q-input
filled
dense
v-model.trim="formData.lud16"
label="Lightning Address (lud16)"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
:loading="saving"
icon="publish"
>Save &amp; Publish</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>

View file

@ -0,0 +1,93 @@
<div>
<q-separator></q-separator>
<!-- Header with toggle -->
<div class="row items-center justify-between q-mt-md q-px-md">
<div class="text-subtitle2">Keys</div>
<q-toggle
v-model="showPrivateKey"
color="primary"
label="Show Private Key"
/>
</div>
<!-- QR Codes Container -->
<div class="row q-col-gutter-md q-pa-md">
<!-- Public Key QR -->
<div class="col-12" :class="showPrivateKey ? 'col-sm-6' : ''">
<q-card flat bordered>
<q-card-section class="text-center">
<div class="text-subtitle2 q-mb-sm">Public Key</div>
<div
class="cursor-pointer q-mx-auto"
style="max-width: 200px"
@click="copyText(publicKey)"
>
<q-responsive :ratio="1">
<lnbits-qrcode
:value="publicKey"
:options="{width: 200}"
:show-buttons="false"
class="rounded-borders"
></lnbits-qrcode>
</q-responsive>
</div>
<div class="q-mt-md text-caption text-mono" style="padding: 0 16px">
<span v-text="publicKey.substring(0, 8)"></span>...<span
v-text="publicKey.substring(publicKey.length - 8)"
></span>
</div>
<q-btn
flat
dense
size="sm"
icon="content_copy"
label="Click to copy"
@click="copyText(publicKey)"
class="q-mt-xs"
/>
</q-card-section>
</q-card>
</div>
<!-- Private Key QR (conditional) -->
<div v-if="showPrivateKey" class="col-12 col-sm-6">
<q-card flat bordered>
<q-card-section class="text-center">
<div class="text-subtitle2 q-mb-sm text-warning">
<q-icon name="warning"></q-icon>
Private Key (Keep Secret!)
</div>
<div
class="cursor-pointer q-mx-auto"
style="max-width: 200px"
@click="copyText(privateKey)"
>
<q-responsive :ratio="1">
<lnbits-qrcode
:value="privateKey"
:options="{width: 200}"
:show-buttons="false"
class="rounded-borders"
></lnbits-qrcode>
</q-responsive>
</div>
<div class="q-mt-md text-caption text-mono" style="padding: 0 16px">
<span v-text="privateKey.substring(0, 8)"></span>...<span
v-text="privateKey.substring(privateKey.length - 8)"
></span>
</div>
<q-btn
flat
dense
size="sm"
icon="content_copy"
label="Click to copy"
@click="copyText(privateKey)"
class="q-mt-xs"
/>
</q-card-section>
</q-card>
</div>
</div>
</div>

View file

@ -1,277 +1,56 @@
<div> <div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-8"> <div class="col-12 col-md-8">
<q-card v-if="publicKey" flat bordered> <div class="row items-center q-col-gutter-sm q-mb-md">
<q-card-section> <div class="col-12 col-sm-auto">
<div class="row items-center no-wrap q-mb-md"> <merchant-details
<div class="col"> :merchant-id="merchantId"
<span class="text-subtitle1">Merchant Profile</span> :inkey="inkey"
</div> :adminkey="adminkey"
</div> :show-keys="showKeys"
<div class="row q-mb-md q-gutter-sm"> @toggle-show-keys="toggleShowKeys"
<q-btn @merchant-deleted="handleMerchantDeleted"
outline ></merchant-details>
color="primary"
@click="showEditProfileDialog = true"
icon="edit"
>Edit</q-btn
>
<q-btn
outline
color="primary"
icon="qr_code"
@click="showKeysDialog = true"
>
<q-tooltip>Show Keys</q-tooltip>
</q-btn>
<q-btn-dropdown
split
outline
color="primary"
icon="swap_horiz"
label="Switch"
>
<q-list>
<q-item-label header>Saved Profiles</q-item-label>
<q-item>
<q-item-section avatar>
<q-icon name="check" color="primary"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label
><span
v-text="merchantConfig?.display_name || merchantConfig?.name || 'Current Profile'"
></span
></q-item-label>
<q-item-label
caption
class="text-mono"
style="font-size: 10px"
>
<span
v-text="publicKey ? publicKey.slice(0, 16) + '...' : ''"
></span>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
flat
dense
round
icon="delete"
color="negative"
size="sm"
@click.stop="removeMerchant"
>
<q-tooltip>Remove profile</q-tooltip>
</q-btn>
</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup @click="$emit('import-key')">
<q-item-section avatar>
<q-icon name="vpn_key" color="primary"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label>Import Existing Key</q-item-label>
<q-item-label caption>Use an existing nsec</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="$emit('generate-key')">
<q-item-section avatar>
<q-icon name="add" color="primary"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label>Generate New Key</q-item-label>
<q-item-label caption>Create a fresh nsec</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn-dropdown
split
outline
:color="merchantActive ? 'positive' : 'negative'"
:icon="merchantActive ? 'shopping_cart' : 'pause_circle'"
:label="merchantActive ? 'Orders On' : 'Orders Off'"
@click="toggleMerchantState"
>
<q-list>
<q-item>
<q-item-section avatar>
<q-icon
:name="merchantActive ? 'check_circle' : 'pause_circle'"
:color="merchantActive ? 'positive' : 'negative'"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label v-if="merchantActive"
>Accepting Orders</q-item-label
>
<q-item-label v-else>Orders Paused</q-item-label>
<q-item-label caption v-if="merchantActive">
New orders will be processed
</q-item-label>
<q-item-label caption v-else>
New orders will be ignored
</q-item-label>
</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup @click="toggleMerchantState">
<q-item-section avatar>
<q-icon
:name="merchantActive ? 'pause_circle' : 'play_circle'"
:color="merchantActive ? 'negative' : 'positive'"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label v-if="merchantActive"
>Pause Orders</q-item-label
>
<q-item-label v-else>Resume Orders</q-item-label>
<q-item-label caption v-if="merchantActive">
Stop accepting new orders
</q-item-label>
<q-item-label caption v-else>
Start accepting new orders
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
</q-card-section>
<!-- Banner Section -->
<div class="q-px-md">
<div
v-if="merchantConfig && merchantConfig.banner"
class="banner-container rounded-borders"
:style="{
height: '120px',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundImage: 'url(' + merchantConfig.banner + ')'
}"
></div>
<div
v-else
class="banner-placeholder bg-grey-3 text-center rounded-borders"
style="height: 120px"
></div>
</div> </div>
<div class="col-12 col-sm-auto q-mx-sm">
<!-- Profile Section --> <div class="row items-center no-wrap">
<q-card-section class="q-pt-none q-ml-md" style="margin-top: -50px"> <q-toggle
<div class="row"> :model-value="merchantActive"
<!-- Profile Image --> @update:model-value="toggleMerchantState()"
<div class="col-auto"> size="md"
<q-avatar size="100px" color="dark" class="profile-avatar"> checked-icon="check"
<img color="primary"
v-if="merchantConfig && merchantConfig.picture" unchecked-icon="clear"
:src="merchantConfig.picture" />
@error="handleImageError" <span
style="object-fit: cover" class="q-ml-sm"
/> v-text="merchantActive ? 'Accepting Orders': 'Orders Paused'"
<q-icon ></span>
v-else
name="person"
size="60px"
color="grey-5"
></q-icon>
</q-avatar>
</div>
<!-- Name, About and NIP-05 -->
<div class="col q-pl-md" style="padding-top: 55px">
<div class="row items-center">
<div class="col">
<div
class="text-h6"
v-if="merchantConfig && merchantConfig.display_name"
>
<span v-text="merchantConfig.display_name"></span>
</div>
<div class="text-caption text-grey" v-else>
(No display name set)
</div>
</div>
<!-- TODO: Unhide when following/followers is implemented -->
<div v-if="false" class="col-auto q-mr-sm">
<div class="row q-gutter-md">
<div class="text-caption text-grey-6">
<span class="text-weight-bold">0</span> Following
<q-tooltip>Not implemented yet</q-tooltip>
</div>
<div class="text-caption text-grey-6">
<span class="text-weight-bold">0</span> Followers
<q-tooltip>Not implemented yet</q-tooltip>
</div>
</div>
</div>
</div>
<div
class="text-body2 text-grey-8 q-mt-xs"
v-if="merchantConfig && merchantConfig.about"
style="max-width: 400px"
>
<span v-text="merchantConfig.about"></span>
</div>
<div class="row q-mt-xs q-gutter-sm">
<div
class="text-caption text-grey-5"
v-if="merchantConfig && merchantConfig.nip05"
>
<q-icon name="verified" color="primary" size="14px"></q-icon>
<span v-text="merchantConfig.nip05" class="q-ml-xs"></span>
</div>
<div
class="text-caption text-grey-5"
v-if="merchantConfig && merchantConfig.lud16"
>
<q-icon name="bolt" color="warning" size="14px"></q-icon>
<span v-text="merchantConfig.lud16" class="q-ml-xs"></span>
</div>
</div>
</div>
</div> </div>
</q-card-section>
<div class="row items-center q-px-md">
<q-separator class="col"></q-separator>
<q-btn fab icon="add" color="primary" class="q-ml-md" disable>
<q-tooltip>New Post (Coming soon)</q-tooltip>
</q-btn>
</div> </div>
</div>
<!-- Feed Section (Not Implemented) --> <div v-if="showKeys" class="q-mt-md">
<q-card-section> <div class="row q-mb-md">
<div class="text-center q-pa-lg" style="opacity: 0.5"> <div class="col">
<q-icon name="chat" size="48px" class="q-mb-sm text-grey"></q-icon> <q-btn
<div class="text-subtitle2 text-grey">Coming Soon</div> unelevated
<div class="text-caption text-grey"> color="grey"
Merchant posts will appear here outline
</div> @click="hideKeys"
class="float-left"
>Hide Keys</q-btn
>
</div> </div>
</q-card-section> </div>
</q-card> <div class="row">
<div class="col">
<key-pair
:public-key="publicKey"
:private-key="privateKey"
></key-pair>
</div>
</div>
</div>
</div> </div>
</div> </div>
<!-- Edit Profile Dialog -->
<edit-profile-dialog
v-model="showEditProfileDialog"
:merchant-id="merchantId"
:merchant-config="merchantConfig"
:adminkey="adminkey"
@profile-updated="$emit('profile-updated')"
></edit-profile-dialog>
<!-- Nostr Keys Dialog -->
<nostr-keys-dialog
v-model="showKeysDialog"
:public-key="publicKey"
:private-key="privateKey"
></nostr-keys-dialog>
</div> </div>

View file

@ -1,75 +0,0 @@
<q-dialog v-model="show">
<q-card style="min-width: 400px; max-width: 450px">
<q-card-section>
<div class="text-h6">Nostr Keys</div>
</q-card-section>
<q-card-section>
<!-- QR Code for npub only -->
<div class="q-mx-auto q-mb-md text-center" style="max-width: 200px">
<lnbits-qrcode
:value="npub"
:options="{ width: 200 }"
:show-buttons="false"
class="rounded-borders"
></lnbits-qrcode>
</div>
<!-- Public Key (npub) -->
<div class="text-subtitle2 q-mb-xs">
<q-icon name="public" class="q-mr-xs"></q-icon>
Public Key (npub)
</div>
<q-input :model-value="npub" readonly dense outlined class="q-mb-md">
<template v-slot:append>
<q-btn
flat
dense
icon="content_copy"
@click="copyText(npub, 'npub copied!')"
>
<q-tooltip>Copy npub</q-tooltip>
</q-btn>
</template>
</q-input>
<!-- Private Key (nsec) -->
<div class="text-subtitle2 q-mb-xs text-warning">
<q-icon name="warning" class="q-mr-xs"></q-icon>
Private Key (nsec)
</div>
<q-input
:model-value="showNsec ? nsec : '••••••••••••••••••••••••••••••••••••••••••••••'"
readonly
dense
outlined
class="q-mb-xs"
>
<template v-slot:append>
<q-btn
flat
dense
:icon="showNsec ? 'visibility_off' : 'visibility'"
@click="showNsec = !showNsec"
>
<q-tooltip v-text="showNsec ? 'Hide' : 'Show'"></q-tooltip>
</q-btn>
<q-btn
flat
dense
icon="content_copy"
@click="copyText(nsec, 'nsec copied! Keep it secret!')"
>
<q-tooltip>Copy nsec</q-tooltip>
</q-btn>
</template>
</q-input>
<div class="text-caption text-negative">
<q-icon name="error" size="14px"></q-icon>
Never share your private key!
</div>
</q-card-section>
<q-card-actions align="right" class="q-pa-md">
<q-btn flat label="CLOSE" color="grey" v-close-popup></q-btn>
</q-card-actions>
</q-card>
</q-dialog>

View file

@ -12,12 +12,6 @@
indicator-color="primary" indicator-color="primary"
align="left" align="left"
> >
<q-tab
name="orders"
label="Orders"
icon="receipt_long"
style="min-width: 120px"
></q-tab>
<q-tab <q-tab
name="merchant" name="merchant"
label="Merchant" label="Merchant"
@ -48,23 +42,6 @@
<q-separator></q-separator> <q-separator></q-separator>
<q-tab-panels v-model="activeTab" animated> <q-tab-panels v-model="activeTab" animated>
<!-- Orders Tab -->
<q-tab-panel name="orders">
<q-card-section>
<div class="text-h6">Orders</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="q-pt-none">
<order-list
ref="orderListRef"
:adminkey="g.user.wallets[0].adminkey"
:inkey="g.user.wallets[0].inkey"
:customer-pubkey-filter="orderPubkey"
@customer-selected="customerSelectedForOrder"
></order-list>
</q-card-section>
</q-tab-panel>
<!-- Merchant Tab --> <!-- Merchant Tab -->
<q-tab-panel name="merchant"> <q-tab-panel name="merchant">
<merchant-tab <merchant-tab
@ -76,15 +53,11 @@
:public-key="merchant.public_key" :public-key="merchant.public_key"
:private-key="merchant.private_key" :private-key="merchant.private_key"
:is-admin="g.user.admin" :is-admin="g.user.admin"
:merchant-config="merchant.config"
@toggle-show-keys="toggleShowKeys" @toggle-show-keys="toggleShowKeys"
@hide-keys="showKeys = false" @hide-keys="showKeys = false"
@merchant-deleted="handleMerchantDeleted" @merchant-deleted="handleMerchantDeleted"
@toggle-merchant-state="toggleMerchantState" @toggle-merchant-state="toggleMerchantState"
@restart-nostr-connection="restartNostrConnection" @restart-nostr-connection="restartNostrConnection"
@import-key="showImportKeysDialog"
@generate-key="generateKeys"
@profile-updated="getMerchant"
></merchant-tab> ></merchant-tab>
</q-tab-panel> </q-tab-panel>
@ -122,6 +95,21 @@
<!-- Messages Tab --> <!-- Messages Tab -->
</q-tab-panels> </q-tab-panels>
</q-card> </q-card>
<q-card class="q-mt-md">
<q-card-section>
<div class="text-h6">Orders</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="q-pt-none">
<order-list
ref="orderListRef"
:adminkey="g.user.wallets[0].adminkey"
:inkey="g.user.wallets[0].inkey"
:customer-pubkey-filter="orderPubkey"
@customer-selected="customerSelectedForOrder"
></order-list>
</q-card-section>
</q-card>
</div> </div>
<q-card v-else> <q-card v-else>
<q-card-section> <q-card-section>
@ -422,63 +410,6 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
</div> </div>
<!-- Generate Key Dialog -->
<q-dialog v-model="generateKeyDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<div class="text-h6 q-mb-md">Generate New Key</div>
<div class="q-mb-md">
<div class="text-subtitle2 q-mb-xs">Public Key (npub)</div>
<q-input :model-value="generateKeyDialog.npub" readonly dense outlined>
<template v-slot:append>
<q-btn
flat
dense
icon="content_copy"
@click="copyText(generateKeyDialog.npub, 'npub copied!')"
></q-btn>
</template>
</q-input>
</div>
<div class="q-mb-md">
<div class="text-subtitle2 q-mb-xs text-warning">
<q-icon name="warning" size="xs"></q-icon>
Private Key (nsec)
</div>
<q-input
:model-value="generateKeyDialog.showNsec ? generateKeyDialog.nsec : '••••••••••••••••••••••••••••••••••••••••••••••'"
readonly
dense
outlined
>
<template v-slot:append>
<q-btn
flat
dense
:icon="generateKeyDialog.showNsec ? 'visibility_off' : 'visibility'"
@click="generateKeyDialog.showNsec = !generateKeyDialog.showNsec"
></q-btn>
<q-btn
flat
dense
icon="content_copy"
@click="copyText(generateKeyDialog.nsec, 'nsec copied! Keep it safe!')"
></q-btn>
</template>
</q-input>
<div class="text-caption text-negative q-mt-xs">
<q-icon name="error" size="xs"></q-icon>
Never share your private key!
</div>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" @click="confirmGenerateKey"
>Create Merchant</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-card>
</q-dialog>
</div> </div>
{% endblock%}{% block scripts %} {{ window_vars(user) }} {% endblock%}{% block scripts %} {{ window_vars(user) }}
@ -496,23 +427,10 @@
margin-left: auto; margin-left: auto;
width: 100%; width: 100%;
} }
.profile-avatar {
border: 3px solid var(--q-dark-page);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.profile-avatar .q-avatar__content {
overflow: hidden;
border-radius: 50%;
}
</style> </style>
<template id="nostr-keys-dialog" <template id="key-pair"
>{% include("nostrmarket/components/nostr-keys-dialog.html") %}</template >{% include("nostrmarket/components/key-pair.html") %}</template
>
<template id="edit-profile-dialog"
>{% include("nostrmarket/components/edit-profile-dialog.html") %}</template
> >
<template id="shipping-zones" <template id="shipping-zones"
>{% include("nostrmarket/components/shipping-zones.html") %}</template >{% include("nostrmarket/components/shipping-zones.html") %}</template
@ -546,8 +464,7 @@
<script src="{{ url_for('nostrmarket_static', path='js/utils.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='js/utils.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/nostr-keys-dialog.js') }}"></script> <script src="{{ static_url_for('nostrmarket/static', 'components/key-pair.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/edit-profile-dialog.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/shipping-zones.js') }}"></script> <script src="{{ static_url_for('nostrmarket/static', 'components/shipping-zones.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/stall-details.js') }}"></script> <script src="{{ static_url_for('nostrmarket/static', 'components/stall-details.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/stall-list.js') }}"></script> <script src="{{ static_url_for('nostrmarket/static', 'components/stall-list.js') }}"></script>

View file

@ -1,18 +1,15 @@
import json import json
from http import HTTPStatus from http import HTTPStatus
from typing import List, Optional
from fastapi import Depends from fastapi import Depends
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from lnbits.core.crud import get_account, update_account from lnbits.core.models import WalletTypeInfo
from lnbits.core.services import websocket_updater from lnbits.core.services import websocket_updater
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
) )
from lnbits.utils.exchange_rates import currencies from lnbits.utils.exchange_rates import currencies
from lnbits.utils.nostr import generate_keypair
from loguru import logger from loguru import logger
from . import nostr_client, nostrmarket_ext from . import nostr_client, nostrmarket_ext
@ -61,12 +58,10 @@ from .crud import (
) )
from .helpers import normalize_public_key from .helpers import normalize_public_key
from .models import ( from .models import (
CreateMerchantRequest,
Customer, Customer,
DirectMessage, DirectMessage,
DirectMessageType, DirectMessageType,
Merchant, Merchant,
MerchantConfig,
Order, Order,
OrderReissue, OrderReissue,
OrderStatusUpdate, OrderStatusUpdate,
@ -94,48 +89,18 @@ from .services import (
@nostrmarket_ext.post("/api/v1/merchant") @nostrmarket_ext.post("/api/v1/merchant")
async def api_create_merchant( async def api_create_merchant(
data: CreateMerchantRequest, data: PartialMerchant,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Merchant: ) -> Merchant:
try: try:
# Check if merchant already exists for this user merchant = await get_merchant_by_pubkey(data.public_key)
assert merchant is None, "A merchant already uses this public key"
merchant = await get_merchant_for_user(wallet.wallet.user) merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant is None, "A merchant already exists for this user" assert merchant is None, "A merchant already exists for this user"
# Get user's account to access their Nostr keypairs merchant = await create_merchant(wallet.wallet.user, data)
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( await create_zone(
merchant.id, merchant.id,
@ -150,7 +115,7 @@ async def api_create_merchant(
await resubscribe_to_all_merchants() await resubscribe_to_all_merchants()
await nostr_client.merchant_temp_subscription(public_key) await nostr_client.merchant_temp_subscription(data.public_key)
return merchant return merchant
except AssertionError as ex: except AssertionError as ex:
@ -169,7 +134,7 @@ async def api_create_merchant(
@nostrmarket_ext.get("/api/v1/merchant") @nostrmarket_ext.get("/api/v1/merchant")
async def api_get_merchant( async def api_get_merchant(
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Optional[Merchant]: ) -> Merchant | None:
try: try:
merchant = await get_merchant_for_user(wallet.wallet.user) merchant = await get_merchant_for_user(wallet.wallet.user)
@ -227,35 +192,6 @@ async def api_delete_merchant(
await subscribe_to_all_merchants() await subscribe_to_all_merchants()
@nostrmarket_ext.patch("/api/v1/merchant/{merchant_id}")
async def api_update_merchant(
merchant_id: str,
config: MerchantConfig,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
assert merchant.id == merchant_id, "Wrong merchant ID"
updated_merchant = await update_merchant(
wallet.wallet.user, merchant_id, config
)
return updated_merchant
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot update merchant",
) from ex
@nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/nostr") @nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/nostr")
async def api_republish_merchant( async def api_republish_merchant(
merchant_id: str, merchant_id: str,
@ -366,7 +302,7 @@ async def api_delete_merchant_on_nostr(
@nostrmarket_ext.get("/api/v1/zone") @nostrmarket_ext.get("/api/v1/zone")
async def api_get_zones( async def api_get_zones(
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> List[Zone]: ) -> list[Zone]:
try: try:
merchant = await get_merchant_for_user(wallet.wallet.user) merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found" assert merchant, "Merchant cannot be found"
@ -566,7 +502,7 @@ async def api_get_stall(
@nostrmarket_ext.get("/api/v1/stall") @nostrmarket_ext.get("/api/v1/stall")
async def api_get_stalls( async def api_get_stalls(
pending: Optional[bool] = False, pending: bool | None = False,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
): ):
try: try:
@ -590,7 +526,7 @@ async def api_get_stalls(
@nostrmarket_ext.get("/api/v1/stall/product/{stall_id}") @nostrmarket_ext.get("/api/v1/stall/product/{stall_id}")
async def api_get_stall_products( async def api_get_stall_products(
stall_id: str, stall_id: str,
pending: Optional[bool] = False, pending: bool | None = False,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
): ):
try: try:
@ -614,9 +550,9 @@ async def api_get_stall_products(
@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}") @nostrmarket_ext.get("/api/v1/stall/order/{stall_id}")
async def api_get_stall_orders( async def api_get_stall_orders(
stall_id: str, stall_id: str,
paid: Optional[bool] = None, paid: bool | None = None,
shipped: Optional[bool] = None, shipped: bool | None = None,
pubkey: Optional[str] = None, pubkey: str | None = None,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
): ):
try: try:
@ -750,7 +686,7 @@ async def api_update_product(
async def api_get_product( async def api_get_product(
product_id: str, product_id: str,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Optional[Product]: ) -> Product | None:
try: try:
merchant = await get_merchant_for_user(wallet.wallet.user) merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found" assert merchant, "Merchant cannot be found"
@ -835,9 +771,9 @@ async def api_get_order(
@nostrmarket_ext.get("/api/v1/order") @nostrmarket_ext.get("/api/v1/order")
async def api_get_orders( async def api_get_orders(
paid: Optional[bool] = None, paid: bool | None = None,
shipped: Optional[bool] = None, shipped: bool | None = None,
pubkey: Optional[str] = None, pubkey: str | None = None,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
): ):
try: try:
@ -923,7 +859,7 @@ async def api_update_order_status(
async def api_restore_order( async def api_restore_order(
event_id: str, event_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Optional[Order]: ) -> Order | None:
try: try:
merchant = await get_merchant_for_user(wallet.wallet.user) merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found" assert merchant, "Merchant cannot be found"
@ -1050,7 +986,7 @@ async def api_reissue_order_invoice(
@nostrmarket_ext.get("/api/v1/message/{public_key}") @nostrmarket_ext.get("/api/v1/message/{public_key}")
async def api_get_messages( async def api_get_messages(
public_key: str, wallet: WalletTypeInfo = Depends(require_invoice_key) public_key: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
) -> List[DirectMessage]: ) -> list[DirectMessage]:
try: try:
merchant = await get_merchant_for_user(wallet.wallet.user) merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found" assert merchant, "Merchant cannot be found"
@ -1106,7 +1042,7 @@ async def api_create_message(
@nostrmarket_ext.get("/api/v1/customer") @nostrmarket_ext.get("/api/v1/customer")
async def api_get_customers( async def api_get_customers(
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> List[Customer]: ) -> list[Customer]:
try: try:
merchant = await get_merchant_for_user(wallet.wallet.user) merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found" assert merchant, "Merchant cannot be found"