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

View file

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

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

View file

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

View file

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

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',
'public-key',
'private-key',
'is-admin',
'merchant-config'
'is-admin'
],
emits: [
'toggle-show-keys',
'hide-keys',
'merchant-deleted',
'toggle-merchant-state',
'restart-nostr-connection',
'profile-updated',
'import-key',
'generate-key'
],
data: function () {
return {
showEditProfileDialog: false,
showKeysDialog: false
}
},
computed: {
marketClientUrl: function () {
return '/nostrmarket/market'
}
},
methods: {
publishProfile: async function () {
try {
await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Profile published to Nostr!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
toggleShowKeys: function () {
this.$emit('toggle-show-keys')
},
@ -59,40 +27,11 @@ window.app.component('merchant-tab', {
handleMerchantDeleted: function () {
this.$emit('merchant-deleted')
},
removeMerchant: function () {
const name =
this.merchantConfig?.display_name ||
this.merchantConfig?.name ||
'this merchant'
LNbits.utils
.confirmDialog(
`Are you sure you want to remove "${name}"? This will delete all associated data (stalls, products, orders, messages).`
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
`/nostrmarket/api/v1/merchant/${this.merchantId}`,
this.adminkey
)
this.$emit('merchant-deleted')
this.$q.notify({
type: 'positive',
message: 'Merchant removed'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
toggleMerchantState: function () {
this.$emit('toggle-merchant-state')
},
restartNostrConnection: function () {
this.$emit('restart-nostr-connection')
},
handleImageError: function (e) {
e.target.style.display = 'none'
}
}
})

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

View file

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

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 class="row q-col-gutter-md">
<div class="col-12 col-md-8">
<q-card v-if="publicKey" flat bordered>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<span class="text-subtitle1">Merchant Profile</span>
</div>
</div>
<div class="row q-mb-md q-gutter-sm">
<q-btn
outline
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>
<!-- Profile Section -->
<q-card-section class="q-pt-none q-ml-md" style="margin-top: -50px">
<div class="row">
<!-- Profile Image -->
<div class="col-auto">
<q-avatar size="100px" color="dark" class="profile-avatar">
<img
v-if="merchantConfig && merchantConfig.picture"
:src="merchantConfig.picture"
@error="handleImageError"
style="object-fit: cover"
/>
<q-icon
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>
</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>
<!-- Feed Section (Not Implemented) -->
<q-card-section>
<div class="text-center q-pa-lg" style="opacity: 0.5">
<q-icon name="chat" size="48px" class="q-mb-sm text-grey"></q-icon>
<div class="text-subtitle2 text-grey">Coming Soon</div>
<div class="text-caption text-grey">
Merchant posts will appear here
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- Edit Profile Dialog -->
<edit-profile-dialog
v-model="showEditProfileDialog"
<div class="row items-center q-col-gutter-sm q-mb-md">
<div class="col-12 col-sm-auto">
<merchant-details
:merchant-id="merchantId"
:merchant-config="merchantConfig"
:inkey="inkey"
:adminkey="adminkey"
@profile-updated="$emit('profile-updated')"
></edit-profile-dialog>
<!-- Nostr Keys Dialog -->
<nostr-keys-dialog
v-model="showKeysDialog"
:show-keys="showKeys"
@toggle-show-keys="toggleShowKeys"
@merchant-deleted="handleMerchantDeleted"
></merchant-details>
</div>
<div class="col-12 col-sm-auto q-mx-sm">
<div class="row items-center no-wrap">
<q-toggle
:model-value="merchantActive"
@update:model-value="toggleMerchantState()"
size="md"
checked-icon="check"
color="primary"
unchecked-icon="clear"
/>
<span
class="q-ml-sm"
v-text="merchantActive ? 'Accepting Orders': 'Orders Paused'"
></span>
</div>
</div>
</div>
<div v-if="showKeys" class="q-mt-md">
<div class="row q-mb-md">
<div class="col">
<q-btn
unelevated
color="grey"
outline
@click="hideKeys"
class="float-left"
>Hide Keys</q-btn
>
</div>
</div>
<div class="row">
<div class="col">
<key-pair
:public-key="publicKey"
:private-key="privateKey"
></nostr-keys-dialog>
></key-pair>
</div>
</div>
</div>
</div>
</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"
align="left"
>
<q-tab
name="orders"
label="Orders"
icon="receipt_long"
style="min-width: 120px"
></q-tab>
<q-tab
name="merchant"
label="Merchant"
@ -48,23 +42,6 @@
<q-separator></q-separator>
<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 -->
<q-tab-panel name="merchant">
<merchant-tab
@ -76,15 +53,11 @@
:public-key="merchant.public_key"
:private-key="merchant.private_key"
:is-admin="g.user.admin"
:merchant-config="merchant.config"
@toggle-show-keys="toggleShowKeys"
@hide-keys="showKeys = false"
@merchant-deleted="handleMerchantDeleted"
@toggle-merchant-state="toggleMerchantState"
@restart-nostr-connection="restartNostrConnection"
@import-key="showImportKeysDialog"
@generate-key="generateKeys"
@profile-updated="getMerchant"
></merchant-tab>
</q-tab-panel>
@ -122,6 +95,21 @@
<!-- Messages Tab -->
</q-tab-panels>
</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>
<q-card v-else>
<q-card-section>
@ -422,63 +410,6 @@
</q-card>
</q-dialog>
</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>
{% endblock%}{% block scripts %} {{ window_vars(user) }}
@ -496,23 +427,10 @@
margin-left: auto;
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>
<template id="nostr-keys-dialog"
>{% include("nostrmarket/components/nostr-keys-dialog.html") %}</template
>
<template id="edit-profile-dialog"
>{% include("nostrmarket/components/edit-profile-dialog.html") %}</template
<template id="key-pair"
>{% include("nostrmarket/components/key-pair.html") %}</template
>
<template id="shipping-zones"
>{% 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/index.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/nostr-keys-dialog.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/edit-profile-dialog.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/key-pair.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-list.js') }}"></script>

View file

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