diff --git a/README.md b/README.md
index 1839351..daa0daf 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,13 @@
+
+
+
+
+
+
+
+[](./LICENSE)
+[](https://github.com/lnbits/lnbits)
+
# Nostr Market ([NIP-15](https://github.com/nostr-protocol/nips/blob/master/15.md)) - [LNbits](https://github.com/lnbits/lnbits) extension
For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions).
@@ -147,3 +157,10 @@ Stall and product are _Parameterized Replaceable Events_ according to [NIP-33](h
Order placing, invoicing, payment details and order statuses are handled over Nostr using [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md).
Customer support is handled over whatever communication method was specified. If communicationg via nostr, [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) is used.
+
+## Powered by LNbits
+
+[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
+
+[](https://shop.lnbits.com/)
+[](https://my.lnbits.com/login)
diff --git a/__init__.py b/__init__.py
index 921c383..cffa9fa 100644
--- a/__init__.py
+++ b/__init__.py
@@ -27,7 +27,11 @@ def nostrmarket_renderer():
nostr_client: NostrClient = NostrClient()
-from .tasks import wait_for_nostr_events, wait_for_paid_invoices # noqa
+from .tasks import ( # noqa
+ subscription_health_monitor,
+ wait_for_nostr_events,
+ wait_for_paid_invoices,
+)
from .views import * # noqa
from .views_api import * # noqa
@@ -65,4 +69,13 @@ def nostrmarket_start():
task3 = create_permanent_unique_task(
"ext_nostrmarket_wait_for_events", _wait_for_nostr_events
)
- scheduled_tasks.extend([task1, task2, task3])
+
+ async def _health_monitor():
+ # start after the subscription is active
+ await asyncio.sleep(20)
+ await subscription_health_monitor(nostr_client)
+
+ task4 = create_permanent_unique_task(
+ "ext_nostrmarket_health_monitor", _health_monitor
+ )
+ scheduled_tasks.extend([task1, task2, task3, task4])
diff --git a/config.json b/config.json
index 1aa34a8..3a670de 100644
--- a/config.json
+++ b/config.json
@@ -1,12 +1,15 @@
{
- "name": "Nostr Market",
+ "id": "nostrmarket",
"version": "1.1.0",
+ "name": "Nostr Market",
+ "repo": "https://github.com/lnbits/nostrmarket",
"short_description": "Nostr Webshop/market on LNbits",
- "tile": "/nostrmarket/static/images/nostr-market.png",
+ "description": "",
+ "tile": "/nostrmarket/static/images/bitcoin-shop.png",
"min_lnbits_version": "1.4.0",
"contributors": [
{
- "name": "motorina0",
+ "name": "Vlad Stan",
"uri": "https://github.com/motorina0",
"role": "Contributor"
},
@@ -19,6 +22,11 @@
"name": "talvasconcelos",
"uri": "https://github.com/talvasconcelos",
"role": "Developer"
+ },
+ {
+ "name": "BenGWeeks",
+ "uri": "https://github.com/BenGWeeks",
+ "role": "Developer"
}
],
"images": [
@@ -44,5 +52,9 @@
],
"description_md": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/description.md",
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/toc.md",
- "license": "MIT"
+ "license": "MIT",
+ "paid_features": "",
+ "tags": ["Nostr", "Marketplace"],
+ "donate": "",
+ "hidden": false
}
diff --git a/crud.py b/crud.py
index 3bec1f2..7bb799b 100644
--- a/crud.py
+++ b/crud.py
@@ -1,4 +1,5 @@
import json
+from typing import List, Optional, Tuple
from lnbits.helpers import urlsafe_short_hash
@@ -43,7 +44,7 @@ async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant:
async def update_merchant(
user_id: str, merchant_id: str, config: MerchantConfig
-) -> Merchant | None:
+) -> Optional[Merchant]:
await db.execute(
f"""
UPDATE nostrmarket.merchants SET meta = :meta, time = {db.timestamp_now}
@@ -54,7 +55,27 @@ async def update_merchant(
return await get_merchant(user_id, merchant_id)
-async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None:
+async def update_merchant_keys(
+ user_id: str, merchant_id: str, private_key: str, public_key: str
+) -> Optional[Merchant]:
+ await db.execute(
+ f"""
+ UPDATE nostrmarket.merchants
+ SET private_key = :private_key, public_key = :public_key,
+ time = {db.timestamp_now}
+ WHERE id = :id AND user_id = :user_id
+ """,
+ {
+ "private_key": private_key,
+ "public_key": public_key,
+ "id": merchant_id,
+ "user_id": user_id,
+ },
+ )
+ return await get_merchant(user_id, merchant_id)
+
+
+async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
await db.execute(
f"""
UPDATE nostrmarket.merchants SET time = {db.timestamp_now}
@@ -65,7 +86,7 @@ async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None:
return await get_merchant(user_id, merchant_id)
-async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None:
+async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
row: dict = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id AND id = :id""",
{
@@ -77,7 +98,7 @@ async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None:
return Merchant.from_row(row) if row else None
-async def get_merchant_by_pubkey(public_key: str) -> Merchant | None:
+async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]:
row: dict = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE public_key = :public_key""",
{"public_key": public_key},
@@ -86,7 +107,7 @@ async def get_merchant_by_pubkey(public_key: str) -> Merchant | None:
return Merchant.from_row(row) if row else None
-async def get_merchants_ids_with_pubkeys() -> list[tuple[str, str]]:
+async def get_merchants_ids_with_pubkeys() -> List[Tuple[str, str]]:
rows: list[dict] = await db.fetchall(
"""SELECT id, public_key FROM nostrmarket.merchants""",
)
@@ -94,7 +115,7 @@ async def get_merchants_ids_with_pubkeys() -> list[tuple[str, str]]:
return [(row["id"], row["public_key"]) for row in rows]
-async def get_merchant_for_user(user_id: str) -> Merchant | None:
+async def get_merchant_for_user(user_id: str) -> Optional[Merchant]:
row: dict = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id """,
{"user_id": user_id},
@@ -137,7 +158,7 @@ async def create_zone(merchant_id: str, data: Zone) -> Zone:
return zone
-async def update_zone(merchant_id: str, z: Zone) -> Zone | None:
+async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]:
await db.execute(
"""
UPDATE nostrmarket.zones
@@ -156,7 +177,7 @@ async def update_zone(merchant_id: str, z: Zone) -> Zone | None:
return await get_zone(merchant_id, z.id)
-async def get_zone(merchant_id: str, zone_id: str) -> Zone | None:
+async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]:
row: dict = await db.fetchone(
"SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id AND id = :id",
{
@@ -167,7 +188,7 @@ async def get_zone(merchant_id: str, zone_id: str) -> Zone | None:
return Zone.from_row(row) if row else None
-async def get_zones(merchant_id: str) -> list[Zone]:
+async def get_zones(merchant_id: str) -> List[Zone]:
rows: list[dict] = await db.fetchall(
"SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id",
{"merchant_id": merchant_id},
@@ -234,7 +255,7 @@ async def create_stall(merchant_id: str, data: Stall) -> Stall:
return stall
-async def get_stall(merchant_id: str, stall_id: str) -> Stall | None:
+async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.stalls
@@ -248,7 +269,7 @@ async def get_stall(merchant_id: str, stall_id: str) -> Stall | None:
return Stall.from_row(row) if row else None
-async def get_stalls(merchant_id: str, pending: bool | None = False) -> list[Stall]:
+async def get_stalls(merchant_id: str, pending: Optional[bool] = False) -> List[Stall]:
rows: list[dict] = await db.fetchall(
"""
SELECT * FROM nostrmarket.stalls
@@ -273,7 +294,7 @@ async def get_last_stall_update_time() -> int:
return row["event_created_at"] or 0 if row else 0
-async def update_stall(merchant_id: str, stall: Stall) -> Stall | None:
+async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]:
await db.execute(
"""
UPDATE nostrmarket.stalls
@@ -397,7 +418,9 @@ async def update_product(merchant_id: str, product: Product) -> Product:
return updated_product
-async def update_product_quantity(product_id: str, new_quantity: int) -> Product | None:
+async def update_product_quantity(
+ product_id: str, new_quantity: int
+) -> Optional[Product]:
await db.execute(
"""
UPDATE nostrmarket.products SET quantity = :quantity
@@ -412,7 +435,7 @@ async def update_product_quantity(product_id: str, new_quantity: int) -> Product
return Product.from_row(row) if row else None
-async def get_product(merchant_id: str, product_id: str) -> Product | None:
+async def get_product(merchant_id: str, product_id: str) -> Optional[Product]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.products
@@ -428,8 +451,8 @@ async def get_product(merchant_id: str, product_id: str) -> Product | None:
async def get_products(
- merchant_id: str, stall_id: str, pending: bool | None = False
-) -> list[Product]:
+ merchant_id: str, stall_id: str, pending: Optional[bool] = False
+) -> List[Product]:
rows: list[dict] = await db.fetchall(
"""
SELECT * FROM nostrmarket.products
@@ -442,8 +465,8 @@ async def get_products(
async def get_products_by_ids(
- merchant_id: str, product_ids: list[str]
-) -> list[Product]:
+ merchant_id: str, product_ids: List[str]
+) -> List[Product]:
# todo: revisit
keys = []
@@ -464,7 +487,7 @@ async def get_products_by_ids(
return [Product.from_row(row) for row in rows]
-async def get_wallet_for_product(product_id: str) -> str | None:
+async def get_wallet_for_product(product_id: str) -> Optional[str]:
row: dict = await db.fetchone(
"""
SELECT s.wallet as wallet FROM nostrmarket.products p
@@ -571,7 +594,7 @@ async def create_order(merchant_id: str, o: Order) -> Order:
return order
-async def get_order(merchant_id: str, order_id: str) -> Order | None:
+async def get_order(merchant_id: str, order_id: str) -> Optional[Order]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.orders
@@ -585,7 +608,7 @@ async def get_order(merchant_id: str, order_id: str) -> Order | None:
return Order.from_row(row) if row else None
-async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None:
+async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Order]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.orders
@@ -599,7 +622,7 @@ async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None
return Order.from_row(row) if row else None
-async def get_orders(merchant_id: str, **kwargs) -> list[Order]:
+async def get_orders(merchant_id: str, **kwargs) -> List[Order]:
q = " AND ".join(
[
f"{field[0]} = :{field[0]}"
@@ -626,7 +649,7 @@ async def get_orders(merchant_id: str, **kwargs) -> list[Order]:
async def get_orders_for_stall(
merchant_id: str, stall_id: str, **kwargs
-) -> list[Order]:
+) -> List[Order]:
q = " AND ".join(
[
f"{field[0]} = :{field[0]}"
@@ -652,7 +675,7 @@ async def get_orders_for_stall(
return [Order.from_row(row) for row in rows]
-async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | None:
+async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Order]:
q = ", ".join(
[
f"{field[0]} = :{field[0]}"
@@ -676,7 +699,7 @@ async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | Non
return await get_order(merchant_id, order_id)
-async def update_order_paid_status(order_id: str, paid: bool) -> Order | None:
+async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]:
await db.execute(
"UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id",
{"paid": paid, "id": order_id},
@@ -690,7 +713,7 @@ async def update_order_paid_status(order_id: str, paid: bool) -> Order | None:
async def update_order_shipped_status(
merchant_id: str, order_id: str, shipped: bool
-) -> Order | None:
+) -> Optional[Order]:
await db.execute(
"""
UPDATE nostrmarket.orders
@@ -754,7 +777,7 @@ async def create_direct_message(
return msg
-async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | None:
+async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMessage]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.direct_messages
@@ -770,7 +793,7 @@ async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | No
async def get_direct_message_by_event_id(
merchant_id: str, event_id: str
-) -> DirectMessage | None:
+) -> Optional[DirectMessage]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.direct_messages
@@ -784,7 +807,7 @@ async def get_direct_message_by_event_id(
return DirectMessage.from_row(row) if row else None
-async def get_direct_messages(merchant_id: str, public_key: str) -> list[DirectMessage]:
+async def get_direct_messages(merchant_id: str, public_key: str) -> List[DirectMessage]:
rows: list[dict] = await db.fetchall(
"""
SELECT * FROM nostrmarket.direct_messages
@@ -796,7 +819,7 @@ async def get_direct_messages(merchant_id: str, public_key: str) -> list[DirectM
return [DirectMessage.from_row(row) for row in rows]
-async def get_orders_from_direct_messages(merchant_id: str) -> list[DirectMessage]:
+async def get_orders_from_direct_messages(merchant_id: str) -> List[DirectMessage]:
rows: list[dict] = await db.fetchall(
"""
SELECT * FROM nostrmarket.direct_messages
@@ -857,7 +880,7 @@ async def create_customer(merchant_id: str, data: Customer) -> Customer:
return customer
-async def get_customer(merchant_id: str, public_key: str) -> Customer | None:
+async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.customers
@@ -871,7 +894,7 @@ async def get_customer(merchant_id: str, public_key: str) -> Customer | None:
return Customer.from_row(row) if row else None
-async def get_customers(merchant_id: str) -> list[Customer]:
+async def get_customers(merchant_id: str) -> List[Customer]:
rows: list[dict] = await db.fetchall(
"SELECT * FROM nostrmarket.customers WHERE merchant_id = :merchant_id",
{"merchant_id": merchant_id},
@@ -879,7 +902,7 @@ async def get_customers(merchant_id: str) -> list[Customer]:
return [Customer.from_row(row) for row in rows]
-async def get_all_unique_customers() -> list[Customer]:
+async def get_all_unique_customers() -> List[Customer]:
q = """
SELECT public_key, MAX(merchant_id) as merchant_id, MAX(event_created_at)
FROM nostrmarket.customers
diff --git a/description.md b/description.md
index 6446ca7..b4fc5d2 100644
--- a/description.md
+++ b/description.md
@@ -1,12 +1,10 @@
-> IMPORTANT: Nostr market needs the nostr-client extension installed.
+Buy and sell products over Nostr using the NIP-15 marketplace protocol.
-Buy and sell things over Nostr, using NIP15 https://github.com/nostr-protocol/nips/blob/master/15.md
+Its functions include:
-Nostr was partly based on the the previous version of this extension "Diagon Alley", so lends itself very well to buying and sellinng over Nostr.
+- Managing products, sales, and customer communication as a merchant
+- Browsing and ordering products as a customer
+- Tracking order status and delivery
+- Communicating via NIP-17 private direct messages (NIP-44 encryption + NIP-59 gift wrapping)
-The Nostr Market extension includes:
-
-- A merchant client to manage products, sales and communication with customers.
-- A customer client to find and order products from merchants, communicate with merchants and track status of ordered products.
-
-All communication happens over NIP04 encrypted DMs.
+A decentralized commerce solution for merchants and buyers who want to trade goods and services over Nostr with end-to-end encrypted communication.
diff --git a/helpers.py b/helpers.py
index dcc0f06..35f0d0f 100644
--- a/helpers.py
+++ b/helpers.py
@@ -1,54 +1,5 @@
-import base64
-import secrets
-
import coincurve
from bech32 import bech32_decode, convertbits
-from cryptography.hazmat.primitives import padding
-from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
-
-
-def get_shared_secret(privkey: str, pubkey: str):
- pk = coincurve.PublicKey(bytes.fromhex("02" + pubkey))
- sk = coincurve.PrivateKey(bytes.fromhex(privkey))
- shared_point = pk.multiply(sk.secret)
-
- shared_point_bytes = shared_point.format(compressed=False)
- x_coord = shared_point_bytes[1:33]
- return x_coord
-
-
-def decrypt_message(encoded_message: str, encryption_key) -> str:
- encoded_data = encoded_message.split("?iv=")
- if len(encoded_data) == 1:
- return encoded_data[0]
- encoded_content, encoded_iv = encoded_data[0], encoded_data[1]
-
- iv = base64.b64decode(encoded_iv)
- cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv))
- encrypted_content = base64.b64decode(encoded_content)
-
- decryptor = cipher.decryptor()
- decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize()
-
- unpadder = padding.PKCS7(128).unpadder()
- unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize()
-
- return unpadded_data.decode()
-
-
-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()
-
- iv = iv if iv else secrets.token_bytes(16)
- cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv))
-
- encryptor = cipher.encryptor()
- encrypted_message = encryptor.update(padded_data) + encryptor.finalize()
-
- base64_message = base64.b64encode(encrypted_message).decode()
- base64_iv = base64.b64encode(iv).decode()
- return f"{base64_message}?iv={base64_iv}"
def sign_message_hash(private_key: str, hash_: bytes) -> str:
@@ -57,17 +8,6 @@ def sign_message_hash(private_key: str, hash_: bytes) -> str:
return sig.hex()
-def test_decrypt_encrypt(encoded_message: str, encryption_key):
- msg = decrypt_message(encoded_message, encryption_key)
-
- # ecrypt using the same initialisation vector
- iv = base64.b64decode(encoded_message.split("?iv=")[1])
- ecrypted_msg = encrypt_message(msg, encryption_key, iv)
- assert (
- encoded_message == ecrypted_msg
- ), f"expected '{encoded_message}', but got '{ecrypted_msg}'"
-
-
def normalize_public_key(pubkey: str) -> str:
if pubkey.startswith("npub1"):
_, decoded_data = bech32_decode(pubkey)
diff --git a/misc-docs/ORDER-DISCOVERY-ANALYSIS.md b/misc-docs/ORDER-DISCOVERY-ANALYSIS.md
new file mode 100644
index 0000000..de393e2
--- /dev/null
+++ b/misc-docs/ORDER-DISCOVERY-ANALYSIS.md
@@ -0,0 +1,320 @@
+# Nostrmarket Order Discovery Analysis
+
+## Executive Summary
+
+This document analyzes the order discovery mechanism in the Nostrmarket extension and identifies why merchants must manually refresh to see new orders instead of receiving them automatically through persistent subscriptions.
+
+---
+
+## Current Architecture
+
+### Two Subscription Systems
+
+The Nostrmarket extension implements two distinct subscription mechanisms for receiving Nostr events:
+
+#### 1. **Persistent Subscriptions (Background Task)**
+
+**Purpose**: Continuous monitoring for new orders, products, and merchant events
+
+**Implementation**:
+
+- Runs via `wait_for_nostr_events()` background task
+- Initiated on extension startup (15-second delay)
+- Creates subscription ID: `nostrmarket-{hash}`
+- Monitors all merchant public keys continuously
+
+**Code Location**: `/nostrmarket/tasks.py:37-49`
+
+```python
+async def wait_for_nostr_events(nostr_client: NostrClient):
+ while True:
+ try:
+ await subscribe_to_all_merchants()
+ while True:
+ message = await nostr_client.get_event()
+ await process_nostr_message(message)
+```
+
+**Subscription Filters**:
+
+- Direct messages (kind 4) - for orders
+- Stall events (kind 30017)
+- Product events (kind 30018)
+- Profile updates (kind 0)
+
+#### 2. **Temporary Subscriptions (Manual Refresh)**
+
+**Purpose**: Catch up on missed events when merchant clicks "Refresh from Nostr"
+
+**Implementation**:
+
+- Duration: 10 seconds only
+- Triggered by user action
+- Creates subscription ID: `merchant-{hash}`
+- Fetches ALL events from time=0
+
+**Code Location**: `/nostrmarket/nostr/nostr_client.py:100-120`
+
+```python
+async def merchant_temp_subscription(self, pk, duration=10):
+ dm_filters = self._filters_for_direct_messages([pk], 0)
+ # ... creates filters with time=0 (all history)
+ await self.send_req_queue.put(["REQ", subscription_id] + merchant_filters)
+ asyncio.create_task(unsubscribe_with_delay(subscription_id, duration))
+```
+
+---
+
+## Problem Identification
+
+### Why Manual Refresh is Required
+
+#### **Issue 1: Timing Window Problem**
+
+The persistent subscription uses timestamps from the last database update:
+
+```python
+async def subscribe_to_all_merchants():
+ last_dm_time = await get_last_direct_messages_created_at()
+ last_stall_time = await get_last_stall_update_time()
+ last_prod_time = await get_last_product_update_time()
+
+ await nostr_client.subscribe_merchants(
+ public_keys, last_dm_time, last_stall_time, last_prod_time, 0
+ )
+```
+
+**Problem**: Events that occur between:
+
+- The last database update time
+- When the subscription becomes active
+ ...are potentially missed
+
+#### **Issue 2: Connection Stability**
+
+The WebSocket connection between components may be unstable:
+
+```
+[Nostrmarket] <--WebSocket--> [Nostrclient] <--WebSocket--> [Nostr Relays]
+ Extension Extension (Global)
+```
+
+**Potential failure points**:
+
+1. Connection drops between nostrmarket → nostrclient
+2. Connection drops between nostrclient → relays
+3. Reconnection doesn't re-establish subscriptions
+
+#### **Issue 3: Subscription State Management**
+
+**Current behavior**:
+
+- Single persistent subscription per merchant
+- No automatic resubscription on failure
+- No heartbeat/keepalive mechanism
+- No verification that subscription is active
+
+#### **Issue 4: Event Processing Delays**
+
+The startup sequence has intentional delays:
+
+```python
+async def _subscribe_to_nostr_client():
+ await asyncio.sleep(10) # Wait for nostrclient
+ await nostr_client.run_forever()
+
+async def _wait_for_nostr_events():
+ await asyncio.sleep(15) # Wait for extension init
+ await wait_for_nostr_events(nostr_client)
+```
+
+**Problem**: Orders arriving during initialization are missed
+
+---
+
+## Why Manual Refresh Works
+
+The temporary subscription succeeds because:
+
+1. **Fetches from time=0**: Gets ALL historical events
+2. **Fresh connection**: Creates new subscription request
+3. **Immediate processing**: No startup delays
+4. **Direct feedback**: User sees results immediately
+
+```python
+# Temporary subscription uses time=0 (all events)
+dm_filters = self._filters_for_direct_messages([pk], 0) # ← 0 means all time
+
+# Persistent subscription uses last update time
+dm_filters = self._filters_for_direct_messages(public_keys, dm_time) # ← can miss events
+```
+
+---
+
+## Impact Analysis
+
+### User Experience Issues
+
+1. **Merchants miss orders** without manual refresh
+2. **No real-time notifications** for new orders
+3. **Uncertainty** about order status
+4. **Extra manual steps** required
+5. **Delayed order fulfillment**
+
+### Technical Implications
+
+1. **Not truly decentralized** - requires active monitoring
+2. **Scalability concerns** - manual refresh doesn't scale
+3. **Reliability issues** - depends on user action
+4. **Performance overhead** - fetching all events repeatedly
+
+---
+
+## Recommended Solutions
+
+### Solution A: Enhanced Persistent Subscriptions
+
+**Implement redundant subscription mechanisms:**
+
+```python
+class EnhancedSubscriptionManager:
+ def __init__(self):
+ self.last_heartbeat = time.time()
+ self.subscription_active = False
+
+ async def maintain_subscription(self):
+ while True:
+ if not self.subscription_active or \
+ time.time() - self.last_heartbeat > 30:
+ await self.resubscribe_with_overlap()
+ await asyncio.sleep(10)
+
+ async def resubscribe_with_overlap(self):
+ # Use timestamp with 5-minute overlap
+ overlap_time = int(time.time()) - 300
+ await subscribe_to_all_merchants(since=overlap_time)
+```
+
+### Solution B: Periodic Auto-Refresh
+
+**Add automatic temporary subscriptions:**
+
+```python
+async def auto_refresh_loop():
+ while True:
+ await asyncio.sleep(60) # Every minute
+ merchants = await get_all_active_merchants()
+ for merchant in merchants:
+ await merchant_temp_subscription(merchant.pubkey, duration=5)
+```
+
+### Solution C: WebSocket Health Monitoring
+
+**Implement connection health checks:**
+
+```python
+class WebSocketHealthMonitor:
+ async def check_connection_health(self):
+ try:
+ # Send ping to nostrclient
+ response = await nostr_client.ping(timeout=5)
+ if not response:
+ await self.reconnect_and_resubscribe()
+ except Exception:
+ await self.reconnect_and_resubscribe()
+```
+
+### Solution D: Event Gap Detection
+
+**Detect and fill gaps in event sequence:**
+
+```python
+async def detect_event_gaps():
+ # Check for gaps in event timestamps
+ last_known = await get_last_event_time()
+ current_time = int(time.time())
+
+ if current_time - last_known > 60: # 1 minute gap
+ # Perform temporary subscription to fill gap
+ await fetch_missing_events(since=last_known)
+```
+
+---
+
+## Implementation Priority
+
+### Phase 1: Quick Fixes (1-2 days)
+
+1. [DONE] Increase temp subscription duration (10s → 30s)
+2. [DONE] Add connection health logging
+3. [DONE] Reduce startup delays
+
+### Phase 2: Reliability (3-5 days)
+
+1. [TODO] Implement subscription heartbeat
+2. [TODO] Add automatic resubscription on failure
+3. [TODO] Create event gap detection
+
+### Phase 3: Full Solution (1-2 weeks)
+
+1. [TODO] WebSocket connection monitoring
+2. [TODO] Redundant subscription system
+3. [TODO] Real-time order notifications
+4. [TODO] Event deduplication logic
+
+---
+
+## Testing Recommendations
+
+### Test Scenarios
+
+1. **Order during startup**: Send order within 15 seconds of server start
+2. **Long-running test**: Keep server running for 24 hours, send periodic orders
+3. **Connection interruption**: Disconnect nostrclient, send order, reconnect
+4. **High volume**: Send 100 orders rapidly
+5. **Network latency**: Add artificial delay between components
+
+### Monitoring Metrics
+
+- Time between order sent → order discovered
+- Percentage of orders requiring manual refresh
+- WebSocket connection uptime
+- Subscription success rate
+- Event processing latency
+
+---
+
+## Conclusion
+
+The current order discovery system relies on manual refresh due to:
+
+1. **Timing gaps** in persistent subscriptions
+2. **Connection stability** issues
+3. **Lack of redundancy** in subscription management
+4. **No automatic recovery** mechanisms
+
+While the temporary subscription (manual refresh) provides a workaround, a proper solution requires implementing connection monitoring, subscription health checks, and automatic gap-filling mechanisms to ensure reliable real-time order discovery.
+
+---
+
+## Appendix: Code References
+
+### Key Files
+
+- `/nostrmarket/tasks.py` - Background task management
+- `/nostrmarket/nostr/nostr_client.py` - Nostr client implementation
+- `/nostrmarket/services.py` - Order processing logic
+- `/nostrmarket/views_api.py` - API endpoints for refresh
+
+### Relevant Functions
+
+- `wait_for_nostr_events()` - Main event loop
+- `subscribe_to_all_merchants()` - Persistent subscription
+- `merchant_temp_subscription()` - Manual refresh
+- `process_nostr_message()` - Event processing
+
+---
+
+_Document prepared: January 2025_
+_Analysis based on: Nostrmarket v1.0_
+_Status: Active Investigation_
diff --git a/models.py b/models.py
index 58842d5..c766cc2 100644
--- a/models.py
+++ b/models.py
@@ -2,17 +2,12 @@ import json
import time
from abc import abstractmethod
from enum import Enum
-from typing import Any
+from typing import Any, List, Optional, Tuple
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
from pydantic import BaseModel
-from .helpers import (
- decrypt_message,
- encrypt_message,
- get_shared_secret,
- sign_message_hash,
-)
+from .helpers import sign_message_hash
from .nostr.event import NostrEvent
######################################## NOSTR ########################################
@@ -32,21 +27,28 @@ class Nostrable:
class MerchantProfile(BaseModel):
- name: str | None = None
- display_name: str | None = None
- about: str | None = None
- picture: str | None = None
- banner: str | None = None
- website: str | None = None
- nip05: str | None = None
- lud16: str | None = None
+ name: Optional[str] = None
+ display_name: Optional[str] = None
+ about: Optional[str] = None
+ picture: Optional[str] = None
+ banner: Optional[str] = None
+ website: Optional[str] = None
+ nip05: Optional[str] = None
+ lud16: Optional[str] = None
class MerchantConfig(MerchantProfile):
- event_id: str | None = None
+ event_id: Optional[str] = None
sync_from_nostr: bool = False
- active: bool = False
- restore_in_progress: bool | None = False
+ # TODO: switched to True for AIO demo; determine if we leave this as True
+ active: bool = True
+ restore_in_progress: Optional[bool] = False
+ # Set at runtime (not persisted) when account keypair != merchant keypair
+ key_mismatch: Optional[bool] = False
+
+
+class CreateMerchantRequest(BaseModel):
+ config: MerchantConfig = MerchantConfig()
class PartialMerchant(BaseModel):
@@ -57,33 +59,11 @@ class PartialMerchant(BaseModel):
class Merchant(PartialMerchant, Nostrable):
id: str
- time: int | None = 0
+ time: Optional[int] = 0
def sign_hash(self, hash_: bytes) -> str:
return sign_message_hash(self.private_key, hash_)
- def decrypt_message(self, encrypted_message: str, public_key: str) -> str:
- encryption_key = get_shared_secret(self.private_key, public_key)
- return decrypt_message(encrypted_message, encryption_key)
-
- def encrypt_message(self, clear_text_message: str, public_key: str) -> str:
- encryption_key = get_shared_secret(self.private_key, public_key)
- return encrypt_message(clear_text_message, encryption_key)
-
- def build_dm_event(self, message: str, to_pubkey: str) -> NostrEvent:
- content = self.encrypt_message(message, to_pubkey)
- event = NostrEvent(
- pubkey=self.public_key,
- created_at=round(time.time()),
- kind=4,
- tags=[["p", to_pubkey]],
- content=content,
- )
- event.id = event.event_id
- event.sig = self.sign_hash(bytes.fromhex(event.id))
-
- return event
-
@classmethod
def from_row(cls, row: dict) -> "Merchant":
merchant = cls(**row)
@@ -139,11 +119,11 @@ class Merchant(PartialMerchant, Nostrable):
######################################## ZONES ########################################
class Zone(BaseModel):
- id: str | None = None
- name: str | None = None
+ id: Optional[str] = None
+ name: Optional[str] = None
currency: str
cost: float
- countries: list[str] = []
+ countries: List[str] = []
@classmethod
def from_row(cls, row: dict) -> "Zone":
@@ -156,22 +136,22 @@ class Zone(BaseModel):
class StallConfig(BaseModel):
- image_url: str | None = None
- description: str | None = None
+ image_url: Optional[str] = None
+ description: Optional[str] = None
class Stall(BaseModel, Nostrable):
- id: str | None = None
+ id: Optional[str] = None
wallet: str
name: str
currency: str = "sat"
- shipping_zones: list[Zone] = []
+ shipping_zones: List[Zone] = []
config: StallConfig = StallConfig()
pending: bool = False
"""Last published nostr event for this Stall"""
- event_id: str | None = None
- event_created_at: int | None = None
+ event_id: Optional[str] = None
+ event_created_at: Optional[int] = None
def validate_stall(self):
for z in self.shipping_zones:
@@ -229,19 +209,19 @@ class ProductShippingCost(BaseModel):
class ProductConfig(BaseModel):
- description: str | None = None
- currency: str | None = None
- use_autoreply: bool | None = False
- autoreply_message: str | None = None
- shipping: list[ProductShippingCost] = []
+ description: Optional[str] = None
+ currency: Optional[str] = None
+ use_autoreply: Optional[bool] = False
+ autoreply_message: Optional[str] = None
+ shipping: List[ProductShippingCost] = []
class Product(BaseModel, Nostrable):
- id: str | None = None
+ id: Optional[str] = None
stall_id: str
name: str
- categories: list[str] = []
- images: list[str] = []
+ categories: List[str] = []
+ images: List[str] = []
price: float
quantity: int
active: bool = True
@@ -249,8 +229,8 @@ class Product(BaseModel, Nostrable):
config: ProductConfig = ProductConfig()
"""Last published nostr event for this Product"""
- event_id: str | None = None
- event_created_at: int | None = None
+ event_id: Optional[str] = None
+ event_created_at: Optional[int] = None
def to_nostr_event(self, pubkey: str) -> NostrEvent:
content = {
@@ -307,7 +287,7 @@ class ProductOverview(BaseModel):
id: str
name: str
price: float
- product_shipping_cost: float | None = None
+ product_shipping_cost: Optional[float] = None
@classmethod
def from_product(cls, p: Product) -> "ProductOverview":
@@ -324,21 +304,21 @@ class OrderItem(BaseModel):
class OrderContact(BaseModel):
- nostr: str | None = None
- phone: str | None = None
- email: str | None = None
+ nostr: Optional[str] = None
+ phone: Optional[str] = None
+ email: Optional[str] = None
class OrderExtra(BaseModel):
- products: list[ProductOverview]
+ products: List[ProductOverview]
currency: str
btc_price: str
shipping_cost: float = 0
shipping_cost_sat: float = 0
- fail_message: str | None = None
+ fail_message: Optional[str] = None
@classmethod
- async def from_products(cls, products: list[Product]):
+ async def from_products(cls, products: List[Product]):
currency = products[0].config.currency if len(products) else "sat"
exchange_rate = (
await btc_price(currency) if currency and currency != "sat" else 1
@@ -354,19 +334,19 @@ class OrderExtra(BaseModel):
class PartialOrder(BaseModel):
id: str
- event_id: str | None = None
- event_created_at: int | None = None
+ event_id: Optional[str] = None
+ event_created_at: Optional[int] = None
public_key: str
merchant_public_key: str
shipping_id: str
- items: list[OrderItem]
- contact: OrderContact | None = None
- address: str | None = None
+ items: List[OrderItem]
+ contact: Optional[OrderContact] = None
+ address: Optional[str] = None
def validate_order(self):
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
- def validate_order_items(self, product_list: list[Product]):
+ def validate_order_items(self, product_list: List[Product]):
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
assert (
len(product_list) != 0
@@ -387,8 +367,8 @@ class PartialOrder(BaseModel):
)
async def costs_in_sats(
- self, products: list[Product], shipping_id: str, stall_shipping_cost: float
- ) -> tuple[float, float]:
+ self, products: List[Product], shipping_id: str, stall_shipping_cost: float
+ ) -> Tuple[float, float]:
product_prices = {}
for p in products:
product_shipping_cost = next(
@@ -417,7 +397,7 @@ class PartialOrder(BaseModel):
return product_cost, stall_shipping_cost
def receipt(
- self, products: list[Product], shipping_id: str, stall_shipping_cost: float
+ self, products: List[Product], shipping_id: str, stall_shipping_cost: float
) -> str:
if len(products) == 0:
return "[No Products]"
@@ -466,7 +446,7 @@ class Order(PartialOrder):
total: float
paid: bool = False
shipped: bool = False
- time: int | None = None
+ time: Optional[int] = None
extra: OrderExtra
@classmethod
@@ -480,14 +460,14 @@ class Order(PartialOrder):
class OrderStatusUpdate(BaseModel):
id: str
- message: str | None = None
- paid: bool | None = False
- shipped: bool | None = None
+ message: Optional[str] = None
+ paid: Optional[bool] = False
+ shipped: Optional[bool] = None
class OrderReissue(BaseModel):
id: str
- shipping_id: str | None = None
+ shipping_id: Optional[str] = None
class PaymentOption(BaseModel):
@@ -497,8 +477,8 @@ class PaymentOption(BaseModel):
class PaymentRequest(BaseModel):
id: str
- message: str | None = None
- payment_options: list[PaymentOption]
+ message: Optional[str] = None
+ payment_options: List[PaymentOption]
######################################## MESSAGE #######################################
@@ -514,16 +494,16 @@ class DirectMessageType(Enum):
class PartialDirectMessage(BaseModel):
- event_id: str | None = None
- event_created_at: int | None = None
+ event_id: Optional[str] = None
+ event_created_at: Optional[int] = None
message: str
public_key: str
type: int = DirectMessageType.PLAIN_TEXT.value
incoming: bool = False
- time: int | None = None
+ time: Optional[int] = None
@classmethod
- def parse_message(cls, msg) -> tuple[DirectMessageType, Any | None]:
+ def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]:
try:
msg_json = json.loads(msg)
if "type" in msg_json:
@@ -546,15 +526,15 @@ class DirectMessage(PartialDirectMessage):
class CustomerProfile(BaseModel):
- name: str | None = None
- about: str | None = None
+ name: Optional[str] = None
+ about: Optional[str] = None
class Customer(BaseModel):
merchant_id: str
public_key: str
- event_created_at: int | None = None
- profile: CustomerProfile | None = None
+ event_created_at: Optional[int] = None
+ profile: Optional[CustomerProfile] = None
unread_messages: int = 0
@classmethod
diff --git a/nostr/nip44.py b/nostr/nip44.py
new file mode 100644
index 0000000..908ad8a
--- /dev/null
+++ b/nostr/nip44.py
@@ -0,0 +1,180 @@
+"""
+NIP-44 v2: Encrypted Payloads (Versioned)
+
+secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256, base64
+
+Reference: https://github.com/nostr-protocol/nips/blob/master/44.md
+"""
+
+import base64
+import hashlib
+import hmac
+import math
+import secrets
+import struct
+
+import coincurve
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
+from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
+from cryptography.hazmat.primitives import hashes
+
+VERSION = 2
+MIN_PLAINTEXT_SIZE = 1
+MAX_PLAINTEXT_SIZE = 65535
+
+
+def get_conversation_key(private_key_hex: str, public_key_hex: str) -> bytes:
+ """
+ Calculate long-term conversation key between two users via ECDH + HKDF-extract.
+ Symmetric: get_conversation_key(a, B) == get_conversation_key(b, A)
+ """
+ pk = coincurve.PublicKey(bytes.fromhex("02" + public_key_hex))
+ sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
+ shared_point = pk.multiply(sk.secret)
+ shared_x = shared_point.format(compressed=False)[1:33]
+
+ # HKDF-extract only (not expand) with salt='nip44-v2'
+ conversation_key = hmac.new(b"nip44-v2", shared_x, hashlib.sha256).digest()
+ return conversation_key
+
+
+def get_message_keys(
+ conversation_key: bytes, nonce: bytes
+) -> tuple[bytes, bytes, bytes]:
+ """
+ Derive per-message keys from conversation_key and nonce using HKDF-expand.
+ Returns (chacha_key, chacha_nonce, hmac_key).
+ """
+ if len(conversation_key) != 32:
+ raise ValueError("invalid conversation_key length")
+ if len(nonce) != 32:
+ raise ValueError("invalid nonce length")
+
+ keys = HKDFExpand(
+ algorithm=hashes.SHA256(),
+ length=76,
+ info=nonce,
+ ).derive(conversation_key)
+
+ chacha_key = keys[0:32]
+ chacha_nonce = keys[32:44]
+ hmac_key = keys[44:76]
+ return chacha_key, chacha_nonce, hmac_key
+
+
+def calc_padded_len(unpadded_len: int) -> int:
+ """Calculate padded length using power-of-two chunking."""
+ if unpadded_len <= 0:
+ raise ValueError("invalid plaintext length")
+ if unpadded_len <= 32:
+ return 32
+ next_power = 1 << (math.floor(math.log2(unpadded_len - 1)) + 1)
+ if next_power <= 256:
+ chunk = 32
+ else:
+ chunk = next_power // 8
+ return chunk * (math.floor((unpadded_len - 1) / chunk) + 1)
+
+
+def _pad(plaintext: str) -> bytes:
+ """Convert plaintext string to padded byte array."""
+ unpadded = plaintext.encode("utf-8")
+ unpadded_len = len(unpadded)
+ if unpadded_len < MIN_PLAINTEXT_SIZE or unpadded_len > MAX_PLAINTEXT_SIZE:
+ raise ValueError(
+ f"invalid plaintext length: {unpadded_len} "
+ f"(must be {MIN_PLAINTEXT_SIZE}..{MAX_PLAINTEXT_SIZE})"
+ )
+ prefix = struct.pack(">H", unpadded_len)
+ padded_len = calc_padded_len(unpadded_len)
+ suffix = b"\x00" * (padded_len - unpadded_len)
+ return prefix + unpadded + suffix
+
+
+def _unpad(padded: bytes) -> str:
+ """Convert padded byte array back to plaintext string."""
+ unpadded_len = struct.unpack(">H", padded[0:2])[0]
+ unpadded = padded[2 : 2 + unpadded_len]
+ if (
+ unpadded_len == 0
+ or len(unpadded) != unpadded_len
+ or len(padded) != 2 + calc_padded_len(unpadded_len)
+ ):
+ raise ValueError("invalid padding")
+ return unpadded.decode("utf-8")
+
+
+def _hmac_aad(key: bytes, message: bytes, aad: bytes) -> bytes:
+ """HMAC-SHA256 with AAD: hmac(key, aad || message)."""
+ if len(aad) != 32:
+ raise ValueError("AAD associated data must be 32 bytes")
+ return hmac.new(key, aad + message, hashlib.sha256).digest()
+
+
+def _chacha20(key: bytes, nonce: bytes, data: bytes) -> bytes:
+ """ChaCha20 encrypt/decrypt with initial counter = 0."""
+ # cryptography's ChaCha20 takes a 16-byte nonce: 4-byte counter (LE) + 12-byte nonce
+ full_nonce = b"\x00\x00\x00\x00" + nonce
+ cipher = Cipher(algorithms.ChaCha20(key, full_nonce), mode=None)
+ encryptor = cipher.encryptor()
+ return encryptor.update(data) + encryptor.finalize()
+
+
+def _decode_payload(payload: str) -> tuple[bytes, bytes, bytes]:
+ """Decode base64 payload into (nonce, ciphertext, mac)."""
+ plen = len(payload)
+ if plen == 0 or payload[0] == "#":
+ raise ValueError("unknown version")
+ if plen < 132 or plen > 87472:
+ raise ValueError("invalid payload size")
+
+ data = base64.b64decode(payload)
+ dlen = len(data)
+ if dlen < 99 or dlen > 65603:
+ raise ValueError("invalid data size")
+
+ vers = data[0]
+ if vers != VERSION:
+ raise ValueError(f"unknown version {vers}")
+
+ nonce = data[1:33]
+ ciphertext = data[33 : dlen - 32]
+ mac = data[dlen - 32 : dlen]
+ return nonce, ciphertext, mac
+
+
+def encrypt(
+ plaintext: str,
+ conversation_key: bytes,
+ nonce: bytes | None = None,
+) -> str:
+ """
+ Encrypt plaintext using NIP-44 v2.
+ Returns base64-encoded payload.
+ """
+ if nonce is None:
+ nonce = secrets.token_bytes(32)
+ if len(nonce) != 32:
+ raise ValueError("invalid nonce length")
+
+ chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce)
+ padded = _pad(plaintext)
+ ciphertext = _chacha20(chacha_key, chacha_nonce, padded)
+ mac = _hmac_aad(hmac_key, ciphertext, nonce)
+ return base64.b64encode(
+ struct.pack("B", VERSION) + nonce + ciphertext + mac
+ ).decode("ascii")
+
+
+def decrypt(payload: str, conversation_key: bytes) -> str:
+ """
+ Decrypt a NIP-44 v2 base64 payload.
+ Returns plaintext string.
+ """
+ nonce, ciphertext, mac = _decode_payload(payload)
+ chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce)
+ calculated_mac = _hmac_aad(hmac_key, ciphertext, nonce)
+ if not hmac.compare_digest(calculated_mac, mac):
+ raise ValueError("invalid MAC")
+ padded_plaintext = _chacha20(chacha_key, chacha_nonce, ciphertext)
+ return _unpad(padded_plaintext)
diff --git a/nostr/nip59.py b/nostr/nip59.py
new file mode 100644
index 0000000..2283bee
--- /dev/null
+++ b/nostr/nip59.py
@@ -0,0 +1,178 @@
+"""
+NIP-59: Gift Wrap
+
+Three-layer protocol for metadata-protected messaging:
+ 1. Rumor (unsigned event) — carries content, deniable if leaked
+ 2. Seal (kind 13) — encrypts rumor, signed by author, no recipient metadata
+ 3. Gift Wrap (kind 1059) — encrypts seal with ephemeral key, has recipient p-tag
+
+Reference: https://github.com/nostr-protocol/nips/blob/master/59.md
+"""
+
+import json
+import secrets
+import time
+from typing import Optional
+
+import coincurve
+
+from .event import NostrEvent
+from .nip44 import decrypt as nip44_decrypt
+from .nip44 import encrypt as nip44_encrypt
+from .nip44 import get_conversation_key
+
+TWO_DAYS = 2 * 24 * 60 * 60
+
+
+def _random_past_timestamp() -> int:
+ """Generate a timestamp randomly in the past 0-2 days for metadata protection."""
+ return int(time.time()) - secrets.randbelow(TWO_DAYS)
+
+
+def _sign_event(event: NostrEvent, private_key_hex: str) -> NostrEvent:
+ """Compute event id and sign it."""
+ event.id = event.event_id
+ sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
+ event.sig = sk.sign_schnorr(bytes.fromhex(event.id)).hex()
+ return event
+
+
+def _pubkey_from_privkey(private_key_hex: str) -> str:
+ """Derive x-only public key hex from private key hex."""
+ sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
+ return sk.public_key.format(compressed=True)[1:].hex()
+
+
+def create_rumor(
+ pubkey: str,
+ content: str,
+ kind: int = 14,
+ tags: Optional[list[list[str]]] = None,
+ created_at: Optional[int] = None,
+) -> NostrEvent:
+ """
+ Create an unsigned rumor event.
+ The event has an id but no signature, making it deniable.
+ """
+ event = NostrEvent(
+ pubkey=pubkey,
+ created_at=created_at or int(time.time()),
+ kind=kind,
+ tags=tags or [],
+ content=content,
+ )
+ event.id = event.event_id
+ # sig intentionally left as None (unsigned)
+ return event
+
+
+def create_seal(
+ rumor: NostrEvent,
+ sender_privkey: str,
+ recipient_pubkey: str,
+) -> NostrEvent:
+ """
+ Create a kind 13 seal: encrypts the rumor for the recipient.
+ Signed by the sender. Tags are always empty.
+ """
+ conv_key = get_conversation_key(sender_privkey, recipient_pubkey)
+ encrypted_rumor = nip44_encrypt(rumor.stringify(), conv_key)
+
+ seal = NostrEvent(
+ pubkey=_pubkey_from_privkey(sender_privkey),
+ created_at=_random_past_timestamp(),
+ kind=13,
+ tags=[],
+ content=encrypted_rumor,
+ )
+ return _sign_event(seal, sender_privkey)
+
+
+def create_gift_wrap(
+ seal: NostrEvent,
+ recipient_pubkey: str,
+) -> NostrEvent:
+ """
+ Create a kind 1059 gift wrap: encrypts the seal with an ephemeral key.
+ The only public metadata is the recipient's p-tag.
+ """
+ ephemeral_privkey = secrets.token_bytes(32).hex()
+ ephemeral_pubkey = _pubkey_from_privkey(ephemeral_privkey)
+
+ conv_key = get_conversation_key(ephemeral_privkey, recipient_pubkey)
+ encrypted_seal = nip44_encrypt(seal.stringify(), conv_key)
+
+ wrap = NostrEvent(
+ pubkey=ephemeral_pubkey,
+ created_at=_random_past_timestamp(),
+ kind=1059,
+ tags=[["p", recipient_pubkey]],
+ content=encrypted_seal,
+ )
+ return _sign_event(wrap, ephemeral_privkey)
+
+
+def unwrap_gift_wrap(
+ gift_wrap: NostrEvent,
+ recipient_privkey: str,
+) -> NostrEvent:
+ """
+ Decrypt a kind 1059 gift wrap to reveal the inner seal.
+ Uses the recipient's private key and the gift wrap's ephemeral pubkey.
+ """
+ conv_key = get_conversation_key(recipient_privkey, gift_wrap.pubkey)
+ seal_json = nip44_decrypt(gift_wrap.content, conv_key)
+ return NostrEvent(**json.loads(seal_json))
+
+
+def unseal(
+ seal: NostrEvent,
+ recipient_privkey: str,
+) -> NostrEvent:
+ """
+ Decrypt a kind 13 seal to reveal the inner rumor.
+ Uses the recipient's private key and the seal's pubkey (the sender).
+ Validates that the rumor's pubkey matches the seal's pubkey.
+ """
+ conv_key = get_conversation_key(recipient_privkey, seal.pubkey)
+ rumor_json = nip44_decrypt(seal.content, conv_key)
+ rumor = NostrEvent(**json.loads(rumor_json))
+
+ if rumor.pubkey != seal.pubkey:
+ raise ValueError(
+ f"rumor pubkey ({rumor.pubkey}) does not match "
+ f"seal pubkey ({seal.pubkey})"
+ )
+ return rumor
+
+
+# --- Convenience functions ---
+
+
+def wrap_message(
+ content: str,
+ sender_privkey: str,
+ sender_pubkey: str,
+ recipient_pubkey: str,
+ kind: int = 14,
+ tags: Optional[list[list[str]]] = None,
+) -> NostrEvent:
+ """
+ Full wrap pipeline: create rumor -> seal -> gift wrap.
+ Returns the gift wrap event ready to publish.
+ """
+ rumor = create_rumor(sender_pubkey, content, kind=kind, tags=tags)
+ seal = create_seal(rumor, sender_privkey, recipient_pubkey)
+ return create_gift_wrap(seal, recipient_pubkey)
+
+
+def unwrap_message(
+ gift_wrap: NostrEvent,
+ recipient_privkey: str,
+) -> NostrEvent:
+ """
+ Full unwrap pipeline: gift wrap -> seal -> rumor.
+ Returns the rumor with sender pubkey and plaintext content.
+ """
+ seal = unwrap_gift_wrap(gift_wrap, recipient_privkey)
+ return unseal(seal, recipient_privkey)
diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py
index a611980..c51d19d 100644
--- a/nostr/nostr_client.py
+++ b/nostr/nostr_client.py
@@ -1,6 +1,8 @@
import asyncio
import json
+import time
from asyncio import Queue
+from collections import OrderedDict
from threading import Thread
from typing import Callable, List, Optional
@@ -12,6 +14,8 @@ from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash
from .event import NostrEvent
+MAX_SEEN_EVENTS = 1000
+
class NostrClient:
def __init__(self):
@@ -20,6 +24,8 @@ class NostrClient:
self.ws: Optional[WebSocketApp] = None
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
self.running = False
+ self._seen_events: OrderedDict[str, None] = OrderedDict()
+ self.last_event_at: float = 0
@property
def is_websocket_connected(self):
@@ -31,9 +37,11 @@ class NostrClient:
logger.debug(f"Connecting to websockets for 'nostrclient' extension...")
relay_endpoint = encrypt_internal_message("relay", urlsafe=True)
+ ws_url = f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}"
+
on_open, on_message, on_error, on_close = self._ws_handlers()
ws = WebSocketApp(
- f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}",
+ ws_url,
on_message=on_message,
on_open=on_open,
on_close=on_close,
@@ -62,10 +70,21 @@ class NostrClient:
logger.warning(ex)
await asyncio.sleep(60)
+ def is_duplicate_event(self, event_id: str) -> bool:
+ """Check if an event has been seen recently. Returns True if duplicate."""
+ if event_id in self._seen_events:
+ return True
+ self._seen_events[event_id] = None
+ if len(self._seen_events) > MAX_SEEN_EVENTS:
+ self._seen_events.popitem(last=False)
+ return False
+
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
+ self.last_event_at = time.time()
return value
async def publish_nostr_event(self, e: NostrEvent):
@@ -91,10 +110,6 @@ class NostrClient:
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
await self.send_req_queue.put(["REQ", self.subscription_id] + merchant_filters)
- logger.debug(
- f"Subscribing to events for: {len(public_keys)} keys. New subscription id: {self.subscription_id}"
- )
-
async def merchant_temp_subscription(self, pk, duration=10):
dm_filters = self._filters_for_direct_messages([pk], 0)
stall_filters = self._filters_for_stall_events([pk], 0)
@@ -135,13 +150,13 @@ class NostrClient:
logger.debug(ex)
def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List:
- in_messages_filter = {"kinds": [4], "#p": public_keys}
- out_messages_filter = {"kinds": [4], "authors": public_keys}
+ # NIP-17/NIP-59: subscribe to kind 1059 gift wraps addressed to our merchants.
+ # With gift wrapping, outgoing messages are self-wrapped (same p-tag filter).
+ gift_wrap_filter: dict = {"kinds": [1059], "#p": public_keys}
if since and since != 0:
- in_messages_filter["since"] = since
- out_messages_filter["since"] = since
+ gift_wrap_filter["since"] = since
- return [in_messages_filter, out_messages_filter]
+ return [gift_wrap_filter]
def _filters_for_stall_events(self, public_keys: List[str], since: int) -> List:
stall_filter = {"kinds": [30017], "authors": public_keys}
@@ -175,16 +190,21 @@ class NostrClient:
def _ws_handlers(self):
def on_open(_):
- logger.info("Connected to 'nostrclient' websocket")
+ logger.debug("[NOSTRMARKET DEBUG] ✅ Connected to 'nostrclient' websocket successfully")
def on_message(_, message):
- self.recieve_event_queue.put_nowait(message)
+ logger.debug(f"[NOSTRMARKET DEBUG] 📨 Received websocket message: {message[:200]}...")
+ try:
+ self.recieve_event_queue.put_nowait(message)
+ logger.debug(f"[NOSTRMARKET DEBUG] 📤 Message queued successfully")
+ except Exception as e:
+ logger.error(f"[NOSTRMARKET] ❌ Failed to queue message: {e}")
def on_error(_, error):
- logger.warning(error)
+ logger.warning(f"[NOSTRMARKET] ❌ Websocket error: {error}")
def on_close(x, status_code, message):
- logger.warning(f"Websocket closed: {x}: '{status_code}' '{message}'")
+ logger.warning(f"[NOSTRMARKET] 🔌 Websocket closed: {x}: '{status_code}' '{message}'")
# force re-subscribe
self.recieve_event_queue.put_nowait(ValueError("Websocket close."))
diff --git a/services.py b/services.py
index b978e39..f57788c 100644
--- a/services.py
+++ b/services.py
@@ -1,7 +1,8 @@
import asyncio
import json
+from typing import List, Optional, Tuple
-from bolt11 import decode
+from lnbits.bolt11 import decode
from lnbits.core.crud import get_wallet
from lnbits.core.services import create_invoice, websocket_updater
from loguru import logger
@@ -11,9 +12,11 @@ from .crud import (
CustomerProfile,
create_customer,
create_direct_message,
+ create_merchant,
create_order,
create_product,
create_stall,
+ create_zone,
get_customer,
get_last_direct_messages_created_at,
get_last_product_update_time,
@@ -41,6 +44,7 @@ from .models import (
DirectMessage,
DirectMessageType,
Merchant,
+ MerchantConfig,
Nostrable,
Order,
OrderContact,
@@ -48,22 +52,26 @@ from .models import (
OrderItem,
OrderStatusUpdate,
PartialDirectMessage,
+ PartialMerchant,
PartialOrder,
PaymentOption,
PaymentRequest,
Product,
Stall,
+ Zone,
)
from .nostr.event import NostrEvent
+from .nostr.nip59 import unwrap_message, wrap_message
async def create_new_order(
merchant_public_key: str, data: PartialOrder
-) -> PaymentRequest | None:
+) -> Optional[PaymentRequest]:
merchant = await get_merchant_by_pubkey(merchant_public_key)
assert merchant, "Cannot find merchant for order!"
- if await get_order(merchant.id, data.id):
+ existing_order = await get_order(merchant.id, data.id)
+ if existing_order:
return None
if data.event_id and await get_order_by_event_id(merchant.id, data.event_id):
return None
@@ -73,20 +81,24 @@ async def create_new_order(
)
await create_order(merchant.id, order)
- return PaymentRequest(
+ payment_request = PaymentRequest(
id=data.id,
payment_options=[PaymentOption(type="ln", link=invoice)],
message=receipt,
)
+ return payment_request
async def build_order_with_payment(
merchant_id: str, merchant_public_key: str, data: PartialOrder
):
+
products = await get_products_by_ids(
merchant_id, [p.product_id for p in data.items]
)
+
data.validate_order_items(products)
+
shipping_zone = await get_zone(merchant_id, data.shipping_id)
assert shipping_zone, f"Shipping zone not found for order '{data.id}'"
@@ -94,6 +106,7 @@ async def build_order_with_payment(
product_cost_sat, shipping_cost_sat = await data.costs_in_sats(
products, shipping_zone.id, shipping_zone.cost
)
+
receipt = data.receipt(products, shipping_zone.id, shipping_zone.cost)
wallet_id = await get_wallet_for_product(data.items[0].product_id)
@@ -104,11 +117,13 @@ async def build_order_with_payment(
merchant_id, product_ids, data.items
)
if not success:
+ logger.error(f"[NOSTRMARKET] ❌ Product quantity check failed: {message}")
raise ValueError(message)
+ total_amount_sat = round(product_cost_sat + shipping_cost_sat)
payment = await create_invoice(
wallet_id=wallet_id,
- amount=round(product_cost_sat + shipping_cost_sat),
+ amount=total_amount_sat,
memo=f"Order '{data.id}' for pubkey '{data.public_key}'",
extra={
"tag": "nostrmarket",
@@ -136,7 +151,7 @@ async def update_merchant_to_nostr(
merchant: Merchant, delete_merchant=False
) -> Merchant:
stalls = await get_stalls(merchant.id)
- event: NostrEvent | None = None
+ event: Optional[NostrEvent] = None
for stall in stalls:
assert stall.id
products = await get_products(merchant.id, stall.id)
@@ -170,6 +185,72 @@ async def sign_and_send_to_nostr(
return event
+async def provision_merchant(
+ user_id: str,
+ wallet_id: str,
+ public_key: str,
+ private_key: str,
+ display_name: Optional[str] = None,
+ config: Optional[MerchantConfig] = None,
+) -> Merchant:
+ """
+ Provision a merchant with a default shipping zone and default stall,
+ and publish the stall to Nostr relays.
+
+ Single source of truth used by:
+ - LNbits user-creation hook (eager, on signup) — see
+ lnbits/core/services/users.py:_create_default_merchant
+ - nostrmarket views_api._auto_create_merchant (lazy, on first GET
+ /api/v1/merchant when a merchant is missing).
+
+ Idempotent on the merchant: if a merchant with this pubkey already
+ exists, returns it without recreating zone/stall.
+ """
+ existing = await get_merchant_by_pubkey(public_key)
+ if existing:
+ return existing
+
+ partial_merchant = PartialMerchant(
+ private_key=private_key,
+ public_key=public_key,
+ config=config or MerchantConfig(),
+ )
+ merchant = await create_merchant(user_id, partial_merchant)
+
+ online_zone = Zone(
+ id=f"online-{merchant.public_key}",
+ name="Online",
+ currency="sat",
+ cost=0,
+ countries=["Free (digital)"],
+ )
+ await create_zone(merchant.id, online_zone)
+
+ name = display_name or "My"
+ default_stall = Stall(
+ wallet=wallet_id,
+ name=f"{name}'s Store",
+ currency="sat",
+ shipping_zones=[online_zone],
+ )
+ default_stall = await create_stall(merchant.id, default_stall)
+
+ # Publish the kind 30017 stall event so customers' clients can resolve
+ # the stall name when they fetch products. Non-fatal on failure: a
+ # later product publish (or webapp self-heal) will retry.
+ try:
+ stall_event = await sign_and_send_to_nostr(merchant, default_stall)
+ default_stall.event_id = stall_event.id
+ await update_stall(merchant.id, default_stall)
+ except Exception as ex:
+ logger.warning(
+ f"[NOSTRMARKET] Failed to publish default stall for "
+ f"merchant {merchant.id}: {ex}"
+ )
+
+ return merchant
+
+
async def handle_order_paid(order_id: str, merchant_pubkey: str):
try:
order = await update_order_paid_status(order_id, True)
@@ -220,7 +301,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
@@ -261,19 +342,34 @@ async def send_dm(
other_pubkey: str,
type_: int,
dm_content: str,
-):
- dm_event = merchant.build_dm_event(dm_content, other_pubkey)
+) -> DirectMessage:
+ # Wrap message to recipient via NIP-59 gift wrap
+ gift_wrap = wrap_message(
+ dm_content,
+ merchant.private_key,
+ merchant.public_key,
+ other_pubkey,
+ )
dm = PartialDirectMessage(
- event_id=dm_event.id,
- event_created_at=dm_event.created_at,
+ event_id=gift_wrap.id,
+ event_created_at=gift_wrap.created_at,
message=dm_content,
public_key=other_pubkey,
type=type_,
)
dm_reply = await create_direct_message(merchant.id, dm)
- await nostr_client.publish_nostr_event(dm_event)
+ await nostr_client.publish_nostr_event(gift_wrap)
+
+ # Also wrap a copy to self for archival
+ self_wrap = wrap_message(
+ dm_content,
+ merchant.private_key,
+ merchant.public_key,
+ merchant.public_key,
+ )
+ await nostr_client.publish_nostr_event(self_wrap)
await websocket_updater(
merchant.id,
@@ -286,11 +382,13 @@ async def send_dm(
),
)
+ return dm_reply
+
async def compute_products_new_quantity(
- merchant_id: str, product_ids: list[str], items: list[OrderItem]
-) -> tuple[bool, list[Product], str]:
- products: list[Product] = await get_products_by_ids(merchant_id, product_ids)
+ merchant_id: str, product_ids: List[str], items: List[OrderItem]
+) -> Tuple[bool, List[Product], str]:
+ products: List[Product] = await get_products_by_ids(merchant_id, product_ids)
for p in products:
required_quantity = next(
@@ -313,23 +411,38 @@ async def compute_products_new_quantity(
async def process_nostr_message(msg: str):
try:
- type_, *rest = json.loads(msg)
+ parsed_msg = json.loads(msg)
+ type_, *rest = parsed_msg
+
if type_.upper() == "EVENT":
+ if len(rest) < 2:
+ logger.warning(f"[NOSTRMARKET] ⚠️ EVENT message missing data: {rest}")
+ return
_, event = rest
event = NostrEvent(**event)
+
+ # Deduplicate events (overlap resubscriptions may deliver duplicates)
+ if nostr_client.is_duplicate_event(event.id):
+ return
+
if event.kind == 0:
await _handle_customer_profile_update(event)
- elif event.kind == 4:
- await _handle_nip04_message(event)
+ elif event.kind == 1059:
+ await _handle_gift_wrap(event)
elif event.kind == 30017:
await _handle_stall(event)
elif event.kind == 30018:
await _handle_product(event)
+ else:
+ logger.info(f"[NOSTRMARKET] ❓ Unhandled event kind: {event.kind} - event: {event.id}")
return
+ else:
+ logger.info(f"[NOSTRMARKET] 🔄 Non-EVENT message type: {type_}")
except Exception as ex:
- logger.debug(ex)
+ logger.error(f"[NOSTRMARKET] ❌ Error processing nostr message: {ex}")
+ logger.error(f"[NOSTRMARKET] 📄 Raw message that failed: {msg}")
async def create_or_update_order_from_dm(
@@ -410,29 +523,41 @@ async def extract_customer_order_from_dm(
return order
-async def _handle_nip04_message(event: NostrEvent):
- merchant_public_key = event.pubkey
- merchant = await get_merchant_by_pubkey(merchant_public_key)
+async def _handle_gift_wrap(event: NostrEvent):
+ """Handle an incoming kind 1059 gift wrap event (NIP-59/NIP-17)."""
+ p_tags = event.tag_values("p")
+ if not p_tags:
+ logger.warning(f"[NOSTRMARKET] ⚠️ Gift wrap has no p-tag: {event.id}")
+ return
+
+ # The p-tag identifies the recipient of the gift wrap
+ recipient_pubkey = p_tags[0]
+ merchant = await get_merchant_by_pubkey(recipient_pubkey)
if not merchant:
- p_tags = event.tag_values("p")
- if len(p_tags) and p_tags[0]:
- merchant_public_key = p_tags[0]
- merchant = await get_merchant_by_pubkey(merchant_public_key)
-
- assert merchant, f"Merchant not found for public key '{merchant_public_key}'"
-
- if event.pubkey == merchant_public_key:
- assert len(event.tag_values("p")) != 0, "Outgong message has no 'p' tag"
- clear_text_msg = merchant.decrypt_message(
- event.content, event.tag_values("p")[0]
+ logger.warning(
+ f"[NOSTRMARKET] ⚠️ No merchant found for gift wrap recipient: {recipient_pubkey}"
)
- 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}'")
+ return
+
+ try:
+ rumor = unwrap_message(event, merchant.private_key)
+ except Exception as ex:
+ logger.error(f"[NOSTRMARKET] ❌ Failed to unwrap gift wrap {event.id}: {ex}")
+ return
+
+ sender_pubkey = rumor.pubkey
+
+ if sender_pubkey == merchant.public_key:
+ # This is a self-addressed wrap (outgoing message archive)
+ # Extract the actual recipient from the rumor's p-tags
+ rumor_p_tags = rumor.tag_values("p")
+ if rumor_p_tags:
+ await _handle_outgoing_dms(rumor, merchant, rumor.content)
+ return
+
+ # Incoming message from a customer
+ await _handle_incoming_dms(rumor, merchant, rumor.content)
async def _handle_incoming_dms(
@@ -482,17 +607,18 @@ async def _handle_outgoing_dms(
async def _handle_incoming_structured_dm(
merchant: Merchant, dm: DirectMessage, json_data: dict
-) -> tuple[DirectMessageType, str | None]:
+) -> Tuple[DirectMessageType, Optional[str]]:
try:
if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active:
json_resp = await _handle_new_order(
merchant.id, merchant.public_key, dm, json_data
)
-
return DirectMessageType.PAYMENT_REQUEST, json_resp
+ else:
+ logger.info(f"[NOSTRMARKET] Skipping order processing - type: {dm.type}, expected: {DirectMessageType.CUSTOMER_ORDER.value}, merchant_active: {merchant.config.active}")
except Exception as ex:
- logger.warning(ex)
+ logger.error(f"[NOSTRMARKET] Error in _handle_incoming_structured_dm: {ex}")
return DirectMessageType.PLAIN_TEXT, None
@@ -531,16 +657,21 @@ async def _persist_dm(
async def reply_to_structured_dm(
merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str
):
- dm_event = merchant.build_dm_event(dm_reply, customer_pubkey)
+ gift_wrap = wrap_message(
+ dm_reply,
+ merchant.private_key,
+ merchant.public_key,
+ customer_pubkey,
+ )
dm = PartialDirectMessage(
- event_id=dm_event.id,
- event_created_at=dm_event.created_at,
+ event_id=gift_wrap.id,
+ event_created_at=gift_wrap.created_at,
message=dm_reply,
public_key=customer_pubkey,
type=dm_type,
)
await create_direct_message(merchant.id, dm)
- await nostr_client.publish_nostr_event(dm_event)
+ await nostr_client.publish_nostr_event(gift_wrap)
await websocket_updater(
merchant.id,
@@ -573,9 +704,31 @@ async def _handle_new_order(
wallet = await get_wallet(wallet_id)
assert wallet, f"Cannot find wallet for product id: {first_product_id}"
+
payment_req = await create_new_order(merchant_public_key, partial_order)
+
+ if payment_req is None:
+ # Return existing order data instead of creating a failed order
+ existing_order = await get_order(merchant_id, partial_order.id)
+ if existing_order and existing_order.invoice_id != "None":
+ # Order exists with invoice, return existing payment request
+ duplicate_response = json.dumps({
+ "type": DirectMessageType.PAYMENT_REQUEST.value,
+ "id": existing_order.id,
+ "message": "Order already received and processed",
+ "payment_options": []
+ }, separators=(",", ":"), ensure_ascii=False)
+ return duplicate_response
+ else:
+ # Order exists but no invoice, skip processing
+ logger.info(f"[NOSTRMARKET] Order exists but no invoice, returning empty string")
+ return ""
+
except Exception as e:
- logger.debug(e)
+ logger.error(f"[NOSTRMARKET] Error creating order: {e}")
+ logger.error(f"[NOSTRMARKET] Order data: {json_data}")
+ logger.error(f"[NOSTRMARKET] Exception type: {type(e).__name__}")
+ logger.error(f"[NOSTRMARKET] Exception details: {str(e)}")
payment_req = await create_new_failed_order(
merchant_id,
merchant_public_key,
@@ -583,12 +736,17 @@ async def _handle_new_order(
json_data,
"Order received, but cannot be processed. Please contact merchant.",
)
- assert payment_req
+
+ if not payment_req:
+ logger.error(f"[NOSTRMARKET] No payment request returned for order: {partial_order.id}")
+ return ""
+
response = {
"type": DirectMessageType.PAYMENT_REQUEST.value,
**payment_req.dict(),
}
- return json.dumps(response, separators=(",", ":"), ensure_ascii=False)
+ response_json = json.dumps(response, separators=(",", ":"), ensure_ascii=False)
+ return response_json
async def create_new_failed_order(
@@ -621,8 +779,11 @@ async def subscribe_to_all_merchants():
last_stall_time = await get_last_stall_update_time()
last_prod_time = await get_last_product_update_time()
+ # Make dm_time more lenient by subtracting 5 minutes to avoid missing recent events
+ lenient_dm_time = max(0, last_dm_time - 300) if last_dm_time > 0 else 0
+
await nostr_client.subscribe_merchants(
- public_keys, last_dm_time, last_stall_time, last_prod_time, 0
+ public_keys, lenient_dm_time, last_stall_time, last_prod_time, 0
)
diff --git a/static/components/merchant-tab.js b/static/components/merchant-tab.js
index 1194420..d993bd4 100644
--- a/static/components/merchant-tab.js
+++ b/static/components/merchant-tab.js
@@ -19,9 +19,7 @@ window.app.component('merchant-tab', {
'merchant-deleted',
'toggle-merchant-state',
'restart-nostr-connection',
- 'profile-updated',
- 'import-key',
- 'generate-key'
+ 'profile-updated'
],
data: function () {
return {
@@ -31,7 +29,13 @@ window.app.component('merchant-tab', {
},
computed: {
marketClientUrl: function () {
- return '/nostrmarket/market'
+ if (!this.publicKey) {
+ return '/nostrmarket/market'
+ }
+
+ const url = new URL('/nostrmarket/market', window.location.origin)
+ url.searchParams.set('merchant', this.publicKey)
+ return url.pathname + url.search
}
},
methods: {
diff --git a/static/js/index.js b/static/js/index.js
index f14bf1d..f5d2e62 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -13,19 +13,6 @@ window.app = Vue.createApp({
orderPubkey: null,
showKeys: false,
stallCount: 0,
- importKeyDialog: {
- show: false,
- data: {
- privateKey: null
- }
- },
- generateKeyDialog: {
- show: false,
- privateKey: null,
- nsec: null,
- npub: null,
- showNsec: false
- },
wsConnection: null,
nostrStatus: {
connected: false,
@@ -49,49 +36,29 @@ window.app = Vue.createApp({
}
},
methods: {
- generateKeys: function () {
- const privateKey = nostr.generatePrivateKey()
- const publicKey = nostr.getPublicKey(privateKey)
- this.generateKeyDialog.privateKey = privateKey
- this.generateKeyDialog.nsec = nostr.nip19.nsecEncode(privateKey)
- this.generateKeyDialog.npub = nostr.nip19.npubEncode(publicKey)
- this.generateKeyDialog.showNsec = false
- this.generateKeyDialog.show = true
- },
- confirmGenerateKey: async function () {
- this.generateKeyDialog.show = false
- await this.createMerchant(this.generateKeyDialog.privateKey)
- },
- importKeys: async function () {
- this.importKeyDialog.show = false
- let privateKey = this.importKeyDialog.data.privateKey
- if (!privateKey) {
- return
- }
- try {
- if (privateKey.toLowerCase().startsWith('nsec')) {
- privateKey = nostr.nip19.decode(privateKey).data
- }
- // Check if this key is already in use
- const publicKey = nostr.getPublicKey(privateKey)
- if (this.merchant?.public_key === publicKey) {
- this.$q.notify({
- type: 'warning',
- message: 'This key is already your current profile'
- })
- return
- }
- } catch (error) {
- this.$q.notify({
- type: 'negative',
- message: `${error}`
+ migrateKeys: async function () {
+ LNbits.utils
+ .confirmDialog(
+ 'This will update your merchant to use your current account Nostr keypair ' +
+ 'and republish all stalls and products under the new identity. ' +
+ 'Existing orders and messages are preserved. Continue?'
+ )
+ .onOk(async () => {
+ try {
+ const {data} = await LNbits.api.request(
+ 'POST',
+ `/nostrmarket/api/v1/merchant/${this.merchant.id}/migrate-keys`,
+ this.g.user.wallets[0].adminkey
+ )
+ this.merchant = data
+ this.$q.notify({
+ type: 'positive',
+ message: 'Merchant keys migrated and stalls republished'
+ })
+ } catch (error) {
+ LNbits.utils.notifyApiError(error)
+ }
})
- return
- }
- await this.createMerchant(privateKey)
- },
- showImportKeysDialog: async function () {
- this.importKeyDialog.show = true
},
toggleShowKeys: function () {
this.showKeys = !this.showKeys
@@ -143,12 +110,9 @@ window.app = Vue.createApp({
this.showKeys = false
this.stallCount = 0
},
- createMerchant: async function (privateKey) {
+ createMerchant: async function () {
try {
- const pubkey = nostr.getPublicKey(privateKey)
const payload = {
- private_key: privateKey,
- public_key: pubkey,
config: {}
}
const {data} = await LNbits.api.request(
@@ -409,7 +373,11 @@ window.app = Vue.createApp({
}
},
created: async function () {
- await this.getMerchant()
+ const merchant = await this.getMerchant()
+ if (!merchant) {
+ // Auto-create merchant using the account's existing Nostr keypair
+ await this.createMerchant()
+ }
await this.checkNostrStatus()
setInterval(async () => {
if (
diff --git a/static/market/js/utils.js b/static/market/js/utils.js
index 2e41b49..8a3a98b 100644
--- a/static/market/js/utils.js
+++ b/static/market/js/utils.js
@@ -1,5 +1,43 @@
var NostrTools = window.NostrTools
+;(function ensureRandomUUID() {
+ if (!globalThis.crypto) {
+ globalThis.crypto = {}
+ }
+ if (!globalThis.crypto.randomUUID) {
+ globalThis.crypto.randomUUID = function () {
+ const getRandomValues = globalThis.crypto.getRandomValues
+ if (getRandomValues) {
+ const bytes = new Uint8Array(16)
+ getRandomValues.call(globalThis.crypto, bytes)
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
+ const hex = Array.from(bytes, b =>
+ b.toString(16).padStart(2, '0')
+ ).join('')
+ return (
+ hex.slice(0, 8) +
+ '-' +
+ hex.slice(8, 12) +
+ '-' +
+ hex.slice(12, 16) +
+ '-' +
+ hex.slice(16, 20) +
+ '-' +
+ hex.slice(20)
+ )
+ }
+
+ let d = Date.now()
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
+ const r = (d + Math.random() * 16) % 16 | 0
+ d = Math.floor(d / 16)
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
+ })
+ }
+ }
+})()
+
var defaultRelays = [
'wss://relay.damus.io',
'wss://relay.snort.social',
@@ -44,13 +82,24 @@ function confirm(message) {
async function hash(string) {
- const utf8 = new TextEncoder().encode(string)
- const hashBuffer = await crypto.subtle.digest('SHA-256', utf8)
- const hashArray = Array.from(new Uint8Array(hashBuffer))
- const hashHex = hashArray
- .map(bytes => bytes.toString(16).padStart(2, '0'))
- .join('')
- return hashHex
+ const subtle = globalThis.crypto && globalThis.crypto.subtle
+ if (subtle && subtle.digest) {
+ const utf8 = new TextEncoder().encode(string)
+ const hashBuffer = await subtle.digest('SHA-256', utf8)
+ const hashArray = Array.from(new Uint8Array(hashBuffer))
+ return hashArray.map(bytes => bytes.toString(16).padStart(2, '0')).join('')
+ }
+
+ // Fallback for non-secure contexts where crypto.subtle is unavailable.
+ return fallbackHash(string)
+}
+
+function fallbackHash(string) {
+ let hash = 5381
+ for (let i = 0; i < string.length; i++) {
+ hash = ((hash << 5) + hash) + string.charCodeAt(i)
+ }
+ return (hash >>> 0).toString(16).padStart(8, '0')
}
function isJson(str) {
diff --git a/tasks.py b/tasks.py
index 013a281..c147936 100644
--- a/tasks.py
+++ b/tasks.py
@@ -1,4 +1,5 @@
import asyncio
+import time
from asyncio import Queue
from lnbits.core.models import Payment
@@ -9,9 +10,13 @@ from .nostr.nostr_client import NostrClient
from .services import (
handle_order_paid,
process_nostr_message,
+ resubscribe_to_all_merchants,
subscribe_to_all_merchants,
)
+HEALTH_CHECK_INTERVAL = 30 # seconds between health checks
+STALE_THRESHOLD = 120 # seconds without events before resubscribing
+
async def wait_for_paid_invoices():
invoice_queue = Queue()
@@ -35,13 +40,38 @@ async def on_invoice_paid(payment: Payment) -> None:
async def wait_for_nostr_events(nostr_client: NostrClient):
+ logger.info("[NOSTRMARKET] Starting wait_for_nostr_events task")
while True:
try:
+ logger.info("[NOSTRMARKET] Subscribing to all merchants...")
await subscribe_to_all_merchants()
while True:
message = await nostr_client.get_event()
await process_nostr_message(message)
except Exception as e:
- logger.warning(f"Subcription failed. Will retry in one minute: {e}")
+ logger.warning(f"[NOSTRMARKET] Subscription failed. Retrying in 10s: {e}")
await asyncio.sleep(10)
+
+
+async def subscription_health_monitor(nostr_client: NostrClient):
+ """
+ Periodically check if events are flowing. If no events have been
+ received for STALE_THRESHOLD seconds, force a resubscription with
+ overlap to catch any missed events.
+ """
+ logger.info("[NOSTRMARKET] Starting subscription health monitor")
+ while True:
+ await asyncio.sleep(HEALTH_CHECK_INTERVAL)
+ try:
+ if not nostr_client.is_websocket_connected:
+ continue
+
+ elapsed = time.time() - nostr_client.last_event_at
+ if nostr_client.last_event_at > 0 and elapsed > STALE_THRESHOLD:
+ logger.warning(
+ f"[NOSTRMARKET] ⚠️ No events for {elapsed:.0f}s, resubscribing..."
+ )
+ await resubscribe_to_all_merchants()
+ except Exception as e:
+ logger.error(f"[NOSTRMARKET] Health monitor error: {e}")
diff --git a/templates/nostrmarket/components/merchant-tab.html b/templates/nostrmarket/components/merchant-tab.html
index 3d932d2..497478a 100644
--- a/templates/nostrmarket/components/merchant-tab.html
+++ b/templates/nostrmarket/components/merchant-tab.html
@@ -9,29 +9,26 @@
- Edit
-
- Show Keys
-
+
+
+
+
+
+ View Keys
+ Show public/private keys
+
+
+
Saved Profiles
@@ -67,27 +64,15 @@
-
-
-
-
-
-
- Import Existing Key
- Use an existing nsec
-
-
-
-
-
-
-
- Generate New Key
- Create a fresh nsec
-
-
+
+
diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html
index 949f8fe..ee3460c 100644
--- a/templates/nostrmarket/index.html
+++ b/templates/nostrmarket/index.html
@@ -3,6 +3,26 @@
+
+
+
+
+ Your account Nostr keypair has changed since this merchant was created.
+ The merchant is still using the old key. Migrate to republish your
+ stalls and products under the new identity.
+
+
+
+
@@ -124,58 +142,9 @@
-
- Welcome to Nostr Market!
- In Nostr Market, merchant and customer communicate via NOSTR relays, so
- loss of money, product information, and reputation become far less
- likely if attacked.
-
-
- Terms
-
-
- merchant - seller of products with
- NOSTR key-pair
-
-
- customer - buyer of products with
- NOSTR key-pair
-
-
- product - item for sale by the
- merchant
-
-
- stall - list of products controlled
- by merchant (a merchant can have multiple stalls)
-
-
- marketplace - clientside software for
- searching stalls and purchasing products
-
-
-
-
-
-
-
- Use an existing private key (hex or npub)
-
-
- A new key pair will be generated for you
-
-
-
+
+
+ Setting up Nostr Market...
@@ -396,89 +365,6 @@
-
-
-
-
-
-
- Import
- Cancel
-
-
-
-
-
-
-
-
-
- Generate New Key
-
-
Public Key (npub)
-
-
-
-
-
-
-
-
-
- Private Key (nsec)
-
-
-
-
-
-
-
-
-
- Never share your private key!
-
-
-
- Create Merchant
- Cancel
-
-
-
{% endblock%}{% block scripts %} {{ window_vars(user) }}
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..22ffb83
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,27 @@
+"""
+Stub out the nostrmarket root package and all LNbits dependencies so that
+nostr/* unit tests can run without the full LNbits environment.
+
+pytest walks up from tests/ and tries to import the parent __init__.py,
+which pulls in fastapi, lnbits, websocket, etc. We preemptively register
+the parent package as a simple module so that import never happens.
+"""
+
+import sys
+import types
+from pathlib import Path
+
+# Register 'nostrmarket' as an already-imported namespace package
+# pointing at the extension root, so pytest doesn't try to exec __init__.py
+_ext_root = Path(__file__).resolve().parent.parent
+_pkg = types.ModuleType("nostrmarket")
+_pkg.__path__ = [str(_ext_root)]
+_pkg.__package__ = "nostrmarket"
+sys.modules["nostrmarket"] = _pkg
+
+# Also ensure the nostr subpackage is importable
+_nostr_dir = _ext_root / "nostr"
+_nostr_pkg = types.ModuleType("nostrmarket.nostr")
+_nostr_pkg.__path__ = [str(_nostr_dir)]
+_nostr_pkg.__package__ = "nostrmarket.nostr"
+sys.modules["nostrmarket.nostr"] = _nostr_pkg
diff --git a/tests/test_nip44.py b/tests/test_nip44.py
new file mode 100644
index 0000000..3e767a6
--- /dev/null
+++ b/tests/test_nip44.py
@@ -0,0 +1,139 @@
+"""Tests for NIP-44 v2 encryption against official spec test vectors."""
+
+import coincurve
+import pytest
+
+from nostr.nip44 import (
+ calc_padded_len,
+ decrypt,
+ encrypt,
+ get_conversation_key,
+ get_message_keys,
+)
+
+
+def pubkey_from_secret(secret_hex: str) -> str:
+ """Derive x-only public key hex from secret key hex."""
+ sk = coincurve.PrivateKey(bytes.fromhex(secret_hex))
+ return sk.public_key.format(compressed=True)[1:].hex()
+
+
+# --- Test vector from NIP-44 spec ---
+
+SPEC_VECTOR = {
+ "sec1": "0000000000000000000000000000000000000000000000000000000000000001",
+ "sec2": "0000000000000000000000000000000000000000000000000000000000000002",
+ "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
+ "nonce": "0000000000000000000000000000000000000000000000000000000000000001",
+ "plaintext": "a",
+ "payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb",
+}
+
+
+class TestConversationKey:
+ def test_spec_vector(self):
+ pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
+ key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
+ assert key.hex() == SPEC_VECTOR["conversation_key"]
+
+ def test_symmetric(self):
+ """conv(a, B) == conv(b, A)"""
+ pub1 = pubkey_from_secret(SPEC_VECTOR["sec1"])
+ pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
+ key_ab = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
+ key_ba = get_conversation_key(SPEC_VECTOR["sec2"], pub1)
+ assert key_ab == key_ba
+
+
+class TestMessageKeys:
+ def test_returns_correct_lengths(self):
+ conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
+ nonce = bytes.fromhex(SPEC_VECTOR["nonce"])
+ chacha_key, chacha_nonce, hmac_key = get_message_keys(conv_key, nonce)
+ assert len(chacha_key) == 32
+ assert len(chacha_nonce) == 12
+ assert len(hmac_key) == 32
+
+ def test_rejects_bad_key_length(self):
+ with pytest.raises(ValueError):
+ get_message_keys(b"\x00" * 16, b"\x00" * 32)
+
+ def test_rejects_bad_nonce_length(self):
+ with pytest.raises(ValueError):
+ get_message_keys(b"\x00" * 32, b"\x00" * 16)
+
+
+class TestPadding:
+ @pytest.mark.parametrize(
+ "unpadded,expected",
+ [
+ (1, 32),
+ (2, 32),
+ (31, 32),
+ (32, 32),
+ (33, 64),
+ (64, 64),
+ (65, 96),
+ (256, 256),
+ (257, 320),
+ (1024, 1024),
+ (65535, 65536),
+ ],
+ )
+ def test_calc_padded_len(self, unpadded, expected):
+ assert calc_padded_len(unpadded) == expected
+
+ def test_rejects_zero(self):
+ with pytest.raises(ValueError):
+ calc_padded_len(0)
+
+
+class TestEncryptDecrypt:
+ def test_spec_vector(self):
+ conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
+ nonce = bytes.fromhex(SPEC_VECTOR["nonce"])
+ payload = encrypt(SPEC_VECTOR["plaintext"], conv_key, nonce)
+ assert payload == SPEC_VECTOR["payload"]
+
+ def test_spec_vector_decrypt(self):
+ conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
+ plaintext = decrypt(SPEC_VECTOR["payload"], conv_key)
+ assert plaintext == SPEC_VECTOR["plaintext"]
+
+ def test_round_trip_short(self):
+ pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
+ conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
+ msg = "x"
+ assert decrypt(encrypt(msg, conv_key), conv_key) == msg
+
+ def test_round_trip_long(self):
+ pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
+ conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
+ msg = "A" * 65535
+ assert decrypt(encrypt(msg, conv_key), conv_key) == msg
+
+ def test_round_trip_unicode(self):
+ pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
+ conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
+ msg = "hello world! \U0001f680\U0001f30e\U0001f4ac"
+ assert decrypt(encrypt(msg, conv_key), conv_key) == msg
+
+ def test_tampered_mac_rejected(self):
+ conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
+ payload = SPEC_VECTOR["payload"]
+ tampered = payload[:-1] + ("a" if payload[-1] != "a" else "b")
+ with pytest.raises(ValueError, match="invalid MAC"):
+ decrypt(tampered, conv_key)
+
+ def test_empty_plaintext_rejected(self):
+ conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
+ with pytest.raises(ValueError, match="invalid plaintext length"):
+ encrypt("", conv_key)
+
+ def test_unknown_version_rejected(self):
+ with pytest.raises(ValueError, match="unknown version"):
+ decrypt("#invalid", bytes(32))
+
+ def test_short_payload_rejected(self):
+ with pytest.raises(ValueError, match="invalid payload size"):
+ decrypt("AAAA", bytes(32))
diff --git a/tests/test_nip59.py b/tests/test_nip59.py
new file mode 100644
index 0000000..e518abf
--- /dev/null
+++ b/tests/test_nip59.py
@@ -0,0 +1,191 @@
+"""Tests for NIP-59 gift wrap protocol."""
+
+import json
+import time
+
+import coincurve
+import pytest
+
+from nostr.nip59 import (
+ create_gift_wrap,
+ create_rumor,
+ create_seal,
+ unseal,
+ unwrap_gift_wrap,
+ unwrap_message,
+ wrap_message,
+)
+
+
+def _generate_keypair() -> tuple[str, str]:
+ """Generate a (privkey_hex, pubkey_hex) pair."""
+ sk = coincurve.PrivateKey()
+ privkey = sk.secret.hex()
+ pubkey = sk.public_key.format(compressed=True)[1:].hex()
+ return privkey, pubkey
+
+
+SENDER_PRIV, SENDER_PUB = _generate_keypair()
+RECIPIENT_PRIV, RECIPIENT_PUB = _generate_keypair()
+
+
+class TestCreateRumor:
+ def test_has_id_but_no_sig(self):
+ rumor = create_rumor(SENDER_PUB, "hello", kind=14)
+ assert rumor.id != ""
+ assert rumor.sig is None
+
+ def test_kind_and_content(self):
+ rumor = create_rumor(SENDER_PUB, "test message", kind=14, tags=[["p", RECIPIENT_PUB]])
+ assert rumor.kind == 14
+ assert rumor.content == "test message"
+ assert rumor.pubkey == SENDER_PUB
+ assert ["p", RECIPIENT_PUB] in rumor.tags
+
+ def test_custom_timestamp(self):
+ ts = 1700000000
+ rumor = create_rumor(SENDER_PUB, "hello", created_at=ts)
+ assert rumor.created_at == ts
+
+
+class TestCreateSeal:
+ def test_kind_13_with_empty_tags(self):
+ rumor = create_rumor(SENDER_PUB, "hello")
+ seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
+ assert seal.kind == 13
+ assert seal.tags == []
+ assert seal.pubkey == SENDER_PUB
+
+ def test_is_signed(self):
+ rumor = create_rumor(SENDER_PUB, "hello")
+ seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
+ assert seal.sig is not None
+ assert len(seal.sig) == 128 # 64 bytes hex
+
+ def test_content_is_encrypted(self):
+ rumor = create_rumor(SENDER_PUB, "hello")
+ seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
+ # Content should be base64 NIP-44 payload, not plaintext JSON
+ assert "hello" not in seal.content
+
+ def test_timestamp_is_randomized(self):
+ rumor = create_rumor(SENDER_PUB, "hello")
+ seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
+ now = int(time.time())
+ # Seal timestamp should be in the past (up to 2 days)
+ assert seal.created_at <= now
+ assert seal.created_at >= now - (2 * 24 * 60 * 60 + 10)
+
+
+class TestCreateGiftWrap:
+ def test_kind_1059_with_p_tag(self):
+ rumor = create_rumor(SENDER_PUB, "hello")
+ seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
+ wrap = create_gift_wrap(seal, RECIPIENT_PUB)
+ assert wrap.kind == 1059
+ assert ["p", RECIPIENT_PUB] in wrap.tags
+
+ def test_uses_ephemeral_key(self):
+ rumor = create_rumor(SENDER_PUB, "hello")
+ seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
+ wrap = create_gift_wrap(seal, RECIPIENT_PUB)
+ # Gift wrap pubkey should be neither sender nor recipient
+ assert wrap.pubkey != SENDER_PUB
+ assert wrap.pubkey != RECIPIENT_PUB
+
+ def test_different_wraps_have_different_ephemeral_keys(self):
+ rumor = create_rumor(SENDER_PUB, "hello")
+ seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
+ wrap1 = create_gift_wrap(seal, RECIPIENT_PUB)
+ wrap2 = create_gift_wrap(seal, RECIPIENT_PUB)
+ assert wrap1.pubkey != wrap2.pubkey
+
+
+class TestUnwrap:
+ def test_unwrap_gift_wrap_returns_seal(self):
+ rumor = create_rumor(SENDER_PUB, "hello")
+ seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
+ wrap = create_gift_wrap(seal, RECIPIENT_PUB)
+
+ recovered_seal = unwrap_gift_wrap(wrap, RECIPIENT_PRIV)
+ assert recovered_seal.kind == 13
+ assert recovered_seal.pubkey == SENDER_PUB
+
+ def test_unseal_returns_rumor(self):
+ rumor = create_rumor(SENDER_PUB, "hello world")
+ seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
+
+ recovered_rumor = unseal(seal, RECIPIENT_PRIV)
+ assert recovered_rumor.content == "hello world"
+ assert recovered_rumor.pubkey == SENDER_PUB
+ assert recovered_rumor.kind == 14
+
+ def test_wrong_key_fails(self):
+ rumor = create_rumor(SENDER_PUB, "secret")
+ seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
+ wrap = create_gift_wrap(seal, RECIPIENT_PUB)
+
+ wrong_priv, _ = _generate_keypair()
+ with pytest.raises(Exception):
+ unwrap_message(wrap, wrong_priv)
+
+
+class TestFullRoundTrip:
+ def test_wrap_unwrap_message(self):
+ content = "Are you going to the party tonight?"
+ wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
+
+ assert wrap.kind == 1059
+ assert ["p", RECIPIENT_PUB] in wrap.tags
+
+ rumor = unwrap_message(wrap, RECIPIENT_PRIV)
+ assert rumor.content == content
+ assert rumor.pubkey == SENDER_PUB
+ assert rumor.kind == 14
+ assert rumor.sig is None
+
+ def test_wrap_with_custom_kind_and_tags(self):
+ tags = [["p", RECIPIENT_PUB], ["subject", "test"]]
+ wrap = wrap_message(
+ "order data",
+ SENDER_PRIV,
+ SENDER_PUB,
+ RECIPIENT_PUB,
+ kind=14,
+ tags=tags,
+ )
+
+ rumor = unwrap_message(wrap, RECIPIENT_PRIV)
+ assert rumor.content == "order data"
+ assert rumor.kind == 14
+ assert ["subject", "test"] in rumor.tags
+
+ def test_self_wrap_for_archival(self):
+ """Merchant wraps a copy to self (same sender and recipient)."""
+ content = '{"type": 1, "payment_options": [{"type": "ln", "link": "lnbc..."}]}'
+ wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, SENDER_PUB)
+
+ rumor = unwrap_message(wrap, SENDER_PRIV)
+ assert rumor.content == content
+ assert rumor.pubkey == SENDER_PUB
+
+ def test_json_content_preserved(self):
+ """Order JSON payloads survive the wrap/unwrap cycle."""
+ order = {
+ "type": 0,
+ "id": "test-order-123",
+ "items": [{"product_id": "abc", "quantity": 2}],
+ "shipping_id": "zone-1",
+ }
+ content = json.dumps(order)
+ wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
+
+ rumor = unwrap_message(wrap, RECIPIENT_PRIV)
+ recovered_order = json.loads(rumor.content)
+ assert recovered_order == order
+
+ def test_unicode_content(self):
+ content = "Payment received! \u2705 Your order is being processed \U0001f4e6"
+ wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
+ rumor = unwrap_message(wrap, RECIPIENT_PRIV)
+ assert rumor.content == content
diff --git a/views_api.py b/views_api.py
index 1e9f5c5..0e78bc3 100644
--- a/views_api.py
+++ b/views_api.py
@@ -1,15 +1,18 @@
import json
from http import HTTPStatus
+from typing import List, Optional
from fastapi import Depends
from fastapi.exceptions import HTTPException
-from lnbits.core.models import WalletTypeInfo
+from lnbits.core.crud import get_account, update_account
from lnbits.core.services import websocket_updater
from lnbits.decorators import (
+ WalletTypeInfo,
require_admin_key,
require_invoice_key,
)
from lnbits.utils.exchange_rates import currencies
+from lnbits.utils.nostr import generate_keypair
from loguru import logger
from . import nostr_client, nostrmarket_ext
@@ -36,6 +39,7 @@ from .crud import (
get_last_direct_messages_time,
get_merchant_by_pubkey,
get_merchant_for_user,
+ update_merchant_keys,
get_order,
get_order_by_event_id,
get_orders,
@@ -58,6 +62,7 @@ from .crud import (
)
from .helpers import normalize_public_key
from .models import (
+ CreateMerchantRequest,
Customer,
DirectMessage,
DirectMessageType,
@@ -78,8 +83,10 @@ from .models import (
from .services import (
build_order_with_payment,
create_or_update_order_from_dm,
+ provision_merchant,
reply_to_structured_dm,
resubscribe_to_all_merchants,
+ send_dm,
sign_and_send_to_nostr,
subscribe_to_all_merchants,
update_merchant_to_nostr,
@@ -88,34 +95,54 @@ from .services import (
######################################## MERCHANT ######################################
+async def _auto_create_merchant(
+ wallet: WalletTypeInfo,
+ config: MerchantConfig | None = None,
+) -> Merchant:
+ """
+ Lazy fallback: provision a merchant from the user's account keypair when
+ the LNbits-side eager provisioning didn't run (e.g., older accounts, or
+ upstream LNbits without our signup hook).
+
+ Delegates to services.provision_merchant — the canonical implementation.
+ """
+ account = await get_account(wallet.wallet.user)
+ assert account, "User account not found"
+
+ # In our fork, accounts always have keypairs. Generate as fallback only
+ # if somehow missing (e.g., upstream LNbits where this isn't auto-set).
+ if not account.pubkey or not account.prvkey:
+ private_key, public_key = generate_keypair()
+ account.pubkey = public_key
+ account.prvkey = private_key
+ await update_account(account)
+
+ merchant = await provision_merchant(
+ user_id=wallet.wallet.user,
+ wallet_id=wallet.wallet.id,
+ public_key=account.pubkey,
+ private_key=account.prvkey,
+ display_name=account.username,
+ config=config,
+ )
+
+ await resubscribe_to_all_merchants()
+ await nostr_client.merchant_temp_subscription(account.pubkey)
+
+ return merchant
+
+
@nostrmarket_ext.post("/api/v1/merchant")
async def api_create_merchant(
- data: PartialMerchant,
+ data: CreateMerchantRequest,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Merchant:
try:
- merchant = await get_merchant_by_pubkey(data.public_key)
- assert merchant is None, "A merchant already uses this public key"
+ merchant = await get_merchant_for_user(wallet.wallet.user)
+ assert merchant is None, "A merchant already exists for this user"
- merchant = await create_merchant(wallet.wallet.user, data)
-
- await create_zone(
- merchant.id,
- Zone(
- id=f"online-{merchant.public_key}",
- name="Online",
- currency="sat",
- cost=0,
- countries=["Free (digital)"],
- ),
- )
-
- await resubscribe_to_all_merchants()
-
- await nostr_client.merchant_temp_subscription(data.public_key)
-
- return merchant
+ return await _auto_create_merchant(wallet, data.config)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -132,12 +159,13 @@ async def api_create_merchant(
@nostrmarket_ext.get("/api/v1/merchant")
async def api_get_merchant(
wallet: WalletTypeInfo = Depends(require_invoice_key),
-) -> Merchant | None:
+) -> Merchant:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
if not merchant:
- return None
+ # Auto-provision merchant from the user's account keypair
+ merchant = await _auto_create_merchant(wallet)
merchant = await touch_merchant(wallet.wallet.user, merchant.id)
assert merchant
@@ -145,6 +173,11 @@ async def api_get_merchant(
assert merchant.time
merchant.config.restore_in_progress = (merchant.time - last_dm_time) < 30
+ # Detect keypair rotation: account key no longer matches merchant key
+ account = await get_account(wallet.wallet.user)
+ if account and account.pubkey and account.pubkey != merchant.public_key:
+ merchant.config.key_mismatch = True
+
return merchant
except Exception as ex:
logger.warning(ex)
@@ -190,6 +223,76 @@ async def api_delete_merchant(
await subscribe_to_all_merchants()
+@nostrmarket_ext.post("/api/v1/merchant/{merchant_id}/migrate-keys")
+async def api_migrate_merchant_keys(
+ merchant_id: str,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+) -> Merchant:
+ """
+ Migrate a merchant to the current account keypair.
+
+ When a user rotates their Nostr keypair, the merchant still holds the old
+ key. This endpoint updates the merchant's keys to match the account,
+ then republishes all stalls and products under the new identity.
+
+ Orders and DM history are preserved (they reference customer pubkeys,
+ not the merchant key). Old stall/product events on relays become
+ orphaned — clients following the new pubkey will see the fresh events.
+ """
+ try:
+ merchant = await get_merchant_for_user(wallet.wallet.user)
+ assert merchant, "Merchant cannot be found"
+ assert merchant.id == merchant_id, "Wrong merchant ID"
+
+ account = await get_account(wallet.wallet.user)
+ assert account and account.pubkey and account.prvkey, (
+ "Account has no Nostr keypair"
+ )
+
+ if account.pubkey == merchant.public_key:
+ return merchant # already in sync
+
+ # Check no other merchant is using the new pubkey
+ existing = await get_merchant_by_pubkey(account.pubkey)
+ assert existing is None, (
+ "Another merchant already uses this public key"
+ )
+
+ old_pubkey = merchant.public_key
+
+ # Update merchant keys in DB
+ merchant = await update_merchant_keys(
+ wallet.wallet.user, merchant.id,
+ account.prvkey, account.pubkey,
+ )
+ assert merchant
+
+ # Republish all stalls and products under the new key
+ merchant = await update_merchant_to_nostr(merchant)
+
+ logger.info(
+ f"[NOSTRMARKET] Migrated merchant {merchant.id} "
+ f"from {old_pubkey[:16]}... to {account.pubkey[:16]}..."
+ )
+
+ # Resubscribe with new pubkey
+ await resubscribe_to_all_merchants()
+
+ return 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 migrate merchant keys",
+ ) from ex
+
+
@nostrmarket_ext.patch("/api/v1/merchant/{merchant_id}")
async def api_update_merchant(
merchant_id: str,
@@ -329,7 +432,7 @@ async def api_delete_merchant_on_nostr(
@nostrmarket_ext.get("/api/v1/zone")
async def api_get_zones(
wallet: WalletTypeInfo = Depends(require_invoice_key),
-) -> list[Zone]:
+) -> List[Zone]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
@@ -529,7 +632,7 @@ async def api_get_stall(
@nostrmarket_ext.get("/api/v1/stall")
async def api_get_stalls(
- pending: bool | None = False,
+ pending: Optional[bool] = False,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
@@ -553,7 +656,7 @@ async def api_get_stalls(
@nostrmarket_ext.get("/api/v1/stall/product/{stall_id}")
async def api_get_stall_products(
stall_id: str,
- pending: bool | None = False,
+ pending: Optional[bool] = False,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
@@ -577,9 +680,9 @@ async def api_get_stall_products(
@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}")
async def api_get_stall_orders(
stall_id: str,
- paid: bool | None = None,
- shipped: bool | None = None,
- pubkey: str | None = None,
+ paid: Optional[bool] = None,
+ shipped: Optional[bool] = None,
+ pubkey: Optional[str] = None,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
@@ -652,6 +755,21 @@ async def api_create_product(
assert stall, "Stall missing for product"
data.config.currency = stall.currency
+ # Re-publish the parent stall before publishing the product. NIP-33
+ # parameterized replaceable events make this idempotent on relays.
+ # This guarantees the customer client never sees a product whose
+ # parent stall isn't on the relay (e.g., when the original stall
+ # publish failed transiently or never ran).
+ try:
+ stall_event = await sign_and_send_to_nostr(merchant, stall)
+ stall.event_id = stall_event.id
+ await update_stall(merchant.id, stall)
+ except Exception as ex:
+ logger.warning(
+ f"[NOSTRMARKET] Failed to refresh stall {stall.id} "
+ f"before product publish: {ex}"
+ )
+
product = await create_product(merchant.id, data=data)
event = await sign_and_send_to_nostr(merchant, product)
@@ -713,7 +831,7 @@ async def api_update_product(
async def api_get_product(
product_id: str,
wallet: WalletTypeInfo = Depends(require_invoice_key),
-) -> Product | None:
+) -> Optional[Product]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
@@ -798,9 +916,9 @@ async def api_get_order(
@nostrmarket_ext.get("/api/v1/order")
async def api_get_orders(
- paid: bool | None = None,
- shipped: bool | None = None,
- pubkey: str | None = None,
+ paid: Optional[bool] = None,
+ shipped: Optional[bool] = None,
+ pubkey: Optional[str] = None,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
@@ -844,27 +962,11 @@ async def api_update_order_status(
ensure_ascii=False,
)
- dm_event = merchant.build_dm_event(dm_content, order.public_key)
-
- dm = PartialDirectMessage(
- event_id=dm_event.id,
- event_created_at=dm_event.created_at,
- message=dm_content,
- public_key=order.public_key,
- type=DirectMessageType.ORDER_PAID_OR_SHIPPED.value,
- )
- await create_direct_message(merchant.id, dm)
-
- await nostr_client.publish_nostr_event(dm_event)
- await websocket_updater(
- merchant.id,
- json.dumps(
- {
- "type": f"dm:{dm.type}",
- "customerPubkey": order.public_key,
- "dm": dm.dict(),
- }
- ),
+ await send_dm(
+ merchant,
+ order.public_key,
+ DirectMessageType.ORDER_PAID_OR_SHIPPED.value,
+ dm_content,
)
return order
@@ -886,7 +988,7 @@ async def api_update_order_status(
async def api_restore_order(
event_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
-) -> Order | None:
+) -> Optional[Order]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
@@ -1013,7 +1115,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"
@@ -1042,14 +1144,13 @@ async def api_create_message(
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
- dm_event = merchant.build_dm_event(data.message, data.public_key)
- data.event_id = dm_event.id
- data.event_created_at = dm_event.created_at
-
- dm = await create_direct_message(merchant.id, data)
- await nostr_client.publish_nostr_event(dm_event)
-
- return dm
+ dm_reply = await send_dm(
+ merchant,
+ data.public_key,
+ data.type,
+ data.message,
+ )
+ return dm_reply
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -1069,7 +1170,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"