diff --git a/.gitignore b/.gitignore
index 056489e..f3a8853 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,7 +22,4 @@ node_modules
*.swp
*.pyo
*.pyc
-*.env
-
-# Claude Code config
-CLAUDE.md
\ No newline at end of file
+*.env
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index db2ef06..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,104 +0,0 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## Project Overview
-
-Nostr Market is an LNbits extension implementing NIP-15 (decentralized marketplace protocol) on Nostr. It enables merchants to create webshops (stalls) and sell products with Lightning Network payments, featuring encrypted customer-merchant communication via NIP-04.
-
-**Prerequisites:** Requires the LNbits [nostrclient](https://github.com/lnbits/nostrclient) extension to be installed and configured.
-
-## Common Commands
-
-All commands are in the Makefile:
-
-```bash
-make format # Run prettier, black, and ruff formatters
-make check # Run mypy, pyright, black check, ruff check, prettier check
-make test # Run pytest with debug mode
-make all # Run format and check
-```
-
-Individual tools:
-
-```bash
-make black # Format Python files
-make ruff # Check and fix Python linting
-make mypy # Static type checking
-make pyright # Python static type checker
-make prettier # Format JS/HTML/CSS files
-```
-
-## Local Development Setup
-
-To run checks locally, install dependencies:
-
-```bash
-# Install Python autotools dependencies (needed for secp256k1)
-sudo apt-get install -y automake autoconf libtool
-
-# Install Python dependencies
-uv sync
-
-# Install Node dependencies (for prettier)
-npm install
-
-# Run all checks
-make check
-```
-
-## Architecture
-
-### Core Layers
-
-1. **API Layer** (`views_api.py`) - REST endpoints for merchants, stalls, products, zones, orders, direct messages
-2. **Business Logic** (`services.py`) - Order processing, Nostr event signing/publishing, message routing, invoice handling
-3. **Data Layer** (`crud.py`) - Async SQLite operations via LNbits db module
-4. **Models** (`models.py`) - Pydantic models for all entities
-
-### Nostr Integration (`nostr/`)
-
-- `nostr_client.py` - WebSocket client connecting to nostrclient extension for relay communication
-- `event.py` - Nostr event model, serialization, ID computation (SHA256), Schnorr signatures
-
-### Background Tasks (`__init__.py`, `tasks.py`)
-
-Three permanent async tasks:
-
-- `wait_for_paid_invoices()` - Lightning payment listener
-- `wait_for_nostr_events()` - Incoming Nostr message processor
-- `_subscribe_to_nostr_client()` - WebSocket connection manager
-
-### Frontend (`static/`, `templates/`)
-
-- Merchant dashboard: `templates/nostrmarket/index.html`
-- Customer marketplace: `templates/nostrmarket/market.html` with Vue.js/Quasar in `static/market/`
-- Use Quasar UI components when possible: https://quasar.dev/components
-
-### Key Data Models
-
-- **Merchant** - Shop owner with Nostr keypair, handles event signing and DM encryption
-- **Stall** - Individual shop with products and shipping zones (kind 30017)
-- **Product** - Items for sale with categories, images, quantity (kind 30018)
-- **Zone** - Shipping configuration by region
-- **Order** - Customer purchases with Lightning invoice tracking
-- **DirectMessage** - Encrypted chat (NIP-04)
-- **Customer** - Buyer profile with Nostr pubkey
-
-### Key Patterns
-
-- **Nostrable Interface** - Base class for models convertible to Nostr events (`to_nostr_event()`, `to_nostr_delete_event()`)
-- **Parameterized Replaceable Events** - Stalls (kind 30017) and Products (kind 30018) per NIP-33
-- **AES-256 Encryption** - Customer-merchant DMs use shared secret from ECDH
-- **JSON Meta Fields** - Complex data (zones, items, config) stored as JSON in database
-
-### Cryptography (`helpers.py`)
-
-- Schnorr signatures for Nostr events
-- NIP-04 encryption/decryption
-- Key derivation and bech32 encoding (npub/nsec)
-
-## Workflow
-
-- Always check GitHub Actions after pushing to verify CI passes
-- Run `make check` locally before pushing to catch issues early
diff --git a/README.md b/README.md
index daa0daf..1839351 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,3 @@
-
-
-
-
-
-
-
-[](./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).
@@ -157,10 +147,3 @@ 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 cffa9fa..921c383 100644
--- a/__init__.py
+++ b/__init__.py
@@ -27,11 +27,7 @@ def nostrmarket_renderer():
nostr_client: NostrClient = NostrClient()
-from .tasks import ( # noqa
- subscription_health_monitor,
- wait_for_nostr_events,
- wait_for_paid_invoices,
-)
+from .tasks import wait_for_nostr_events, wait_for_paid_invoices # noqa
from .views import * # noqa
from .views_api import * # noqa
@@ -69,13 +65,4 @@ def nostrmarket_start():
task3 = create_permanent_unique_task(
"ext_nostrmarket_wait_for_events", _wait_for_nostr_events
)
-
- 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])
+ scheduled_tasks.extend([task1, task2, task3])
diff --git a/config.json b/config.json
index 3a670de..a02b0b0 100644
--- a/config.json
+++ b/config.json
@@ -1,15 +1,12 @@
{
- "id": "nostrmarket",
- "version": "1.1.0",
"name": "Nostr Market",
- "repo": "https://github.com/lnbits/nostrmarket",
+ "version": "1.1.0",
"short_description": "Nostr Webshop/market on LNbits",
- "description": "",
"tile": "/nostrmarket/static/images/bitcoin-shop.png",
"min_lnbits_version": "1.4.0",
"contributors": [
{
- "name": "Vlad Stan",
+ "name": "motorina0",
"uri": "https://github.com/motorina0",
"role": "Contributor"
},
@@ -22,11 +19,6 @@
"name": "talvasconcelos",
"uri": "https://github.com/talvasconcelos",
"role": "Developer"
- },
- {
- "name": "BenGWeeks",
- "uri": "https://github.com/BenGWeeks",
- "role": "Developer"
}
],
"images": [
@@ -52,9 +44,5 @@
],
"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",
- "paid_features": "",
- "tags": ["Nostr", "Marketplace"],
- "donate": "",
- "hidden": false
+ "license": "MIT"
}
diff --git a/crud.py b/crud.py
index 2fe8453..17282d7 100644
--- a/crud.py
+++ b/crud.py
@@ -1,5 +1,4 @@
import json
-from typing import List, Optional, Tuple
from lnbits.helpers import urlsafe_short_hash
@@ -23,19 +22,16 @@ from .models import (
async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant:
merchant_id = urlsafe_short_hash()
- # Post-aiolabs/nostrmarket#5: no `private_key` column written. The
- # legacy column is dropped by `migrations_fork.m001_aio_drop_private_key`
- # for fresh installs and NULL-tolerated for the brief window between
- # this code change deploying and the fork-migration running.
await db.execute(
"""
INSERT INTO nostrmarket.merchants
- (user_id, id, public_key, meta)
- VALUES (:user_id, :id, :public_key, :meta)
+ (user_id, id, private_key, public_key, meta)
+ VALUES (:user_id, :id, :private_key, :public_key, :meta)
""",
{
"user_id": user_id,
"id": merchant_id,
+ "private_key": m.private_key,
"public_key": m.public_key,
"meta": json.dumps(dict(m.config)),
},
@@ -47,7 +43,7 @@ async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant:
async def update_merchant(
user_id: str, merchant_id: str, config: MerchantConfig
-) -> Optional[Merchant]:
+) -> Merchant | None:
await db.execute(
f"""
UPDATE nostrmarket.merchants SET meta = :meta, time = {db.timestamp_now}
@@ -58,33 +54,7 @@ async def update_merchant(
return await get_merchant(user_id, merchant_id)
-async def update_merchant_pubkey(
- user_id: str, merchant_id: str, public_key: str
-) -> Optional[Merchant]:
- """Re-point a merchant's identity to a new pubkey (e.g. after the
- account migrated to a fresh RemoteBunkerSigner keypair).
-
- Post-aiolabs/nostrmarket#5: there is no `private_key` column to
- update — the merchant pubkey is the only stored identity material,
- and the signing nsec lives entirely in the bunker against
- `account.id` (== `merchant.user_id`) on the lnbits side.
- """
- await db.execute(
- f"""
- UPDATE nostrmarket.merchants
- SET public_key = :public_key, time = {db.timestamp_now}
- WHERE id = :id AND user_id = :user_id
- """,
- {
- "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]:
+async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None:
await db.execute(
f"""
UPDATE nostrmarket.merchants SET time = {db.timestamp_now}
@@ -95,7 +65,7 @@ async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
return await get_merchant(user_id, merchant_id)
-async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
+async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None:
row: dict = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id AND id = :id""",
{
@@ -107,7 +77,7 @@ async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
return Merchant.from_row(row) if row else None
-async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]:
+async def get_merchant_by_pubkey(public_key: str) -> Merchant | None:
row: dict = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE public_key = :public_key""",
{"public_key": public_key},
@@ -116,7 +86,7 @@ async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]:
return Merchant.from_row(row) if row else None
-async def get_merchants_ids_with_pubkeys() -> List[Tuple[str, str]]:
+async def get_merchants_ids_with_pubkeys() -> list[tuple[str, str]]:
rows: list[dict] = await db.fetchall(
"""SELECT id, public_key FROM nostrmarket.merchants""",
)
@@ -124,7 +94,7 @@ async def get_merchants_ids_with_pubkeys() -> List[Tuple[str, str]]:
return [(row["id"], row["public_key"]) for row in rows]
-async def get_merchant_for_user(user_id: str) -> Optional[Merchant]:
+async def get_merchant_for_user(user_id: str) -> Merchant | None:
row: dict = await db.fetchone(
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id """,
{"user_id": user_id},
@@ -167,7 +137,7 @@ async def create_zone(merchant_id: str, data: Zone) -> Zone:
return zone
-async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]:
+async def update_zone(merchant_id: str, z: Zone) -> Zone | None:
await db.execute(
"""
UPDATE nostrmarket.zones
@@ -186,7 +156,7 @@ async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]:
return await get_zone(merchant_id, z.id)
-async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]:
+async def get_zone(merchant_id: str, zone_id: str) -> Zone | None:
row: dict = await db.fetchone(
"SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id AND id = :id",
{
@@ -197,7 +167,7 @@ async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]:
return Zone.from_row(row) if row else None
-async def get_zones(merchant_id: str) -> List[Zone]:
+async def get_zones(merchant_id: str) -> list[Zone]:
rows: list[dict] = await db.fetchall(
"SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id",
{"merchant_id": merchant_id},
@@ -264,7 +234,7 @@ async def create_stall(merchant_id: str, data: Stall) -> Stall:
return stall
-async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]:
+async def get_stall(merchant_id: str, stall_id: str) -> Stall | None:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.stalls
@@ -278,7 +248,7 @@ async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]:
return Stall.from_row(row) if row else None
-async def get_stalls(merchant_id: str, pending: Optional[bool] = False) -> List[Stall]:
+async def get_stalls(merchant_id: str, pending: bool | None = False) -> list[Stall]:
rows: list[dict] = await db.fetchall(
"""
SELECT * FROM nostrmarket.stalls
@@ -303,7 +273,7 @@ async def get_last_stall_update_time() -> int:
return row["event_created_at"] or 0 if row else 0
-async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]:
+async def update_stall(merchant_id: str, stall: Stall) -> Stall | None:
await db.execute(
"""
UPDATE nostrmarket.stalls
@@ -427,9 +397,7 @@ async def update_product(merchant_id: str, product: Product) -> Product:
return updated_product
-async def update_product_quantity(
- product_id: str, new_quantity: int
-) -> Optional[Product]:
+async def update_product_quantity(product_id: str, new_quantity: int) -> Product | None:
await db.execute(
"""
UPDATE nostrmarket.products SET quantity = :quantity
@@ -444,7 +412,7 @@ async def update_product_quantity(
return Product.from_row(row) if row else None
-async def get_product(merchant_id: str, product_id: str) -> Optional[Product]:
+async def get_product(merchant_id: str, product_id: str) -> Product | None:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.products
@@ -460,8 +428,8 @@ async def get_product(merchant_id: str, product_id: str) -> Optional[Product]:
async def get_products(
- merchant_id: str, stall_id: str, pending: Optional[bool] = False
-) -> List[Product]:
+ merchant_id: str, stall_id: str, pending: bool | None = False
+) -> list[Product]:
rows: list[dict] = await db.fetchall(
"""
SELECT * FROM nostrmarket.products
@@ -474,8 +442,8 @@ async def get_products(
async def get_products_by_ids(
- merchant_id: str, product_ids: List[str]
-) -> List[Product]:
+ merchant_id: str, product_ids: list[str]
+) -> list[Product]:
# todo: revisit
keys = []
@@ -496,7 +464,7 @@ async def get_products_by_ids(
return [Product.from_row(row) for row in rows]
-async def get_wallet_for_product(product_id: str) -> Optional[str]:
+async def get_wallet_for_product(product_id: str) -> str | None:
row: dict = await db.fetchone(
"""
SELECT s.wallet as wallet FROM nostrmarket.products p
@@ -603,7 +571,7 @@ async def create_order(merchant_id: str, o: Order) -> Order:
return order
-async def get_order(merchant_id: str, order_id: str) -> Optional[Order]:
+async def get_order(merchant_id: str, order_id: str) -> Order | None:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.orders
@@ -617,7 +585,7 @@ async def get_order(merchant_id: str, order_id: str) -> Optional[Order]:
return Order.from_row(row) if row else None
-async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Order]:
+async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.orders
@@ -631,7 +599,7 @@ async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Ord
return Order.from_row(row) if row else None
-async def get_orders(merchant_id: str, **kwargs) -> List[Order]:
+async def get_orders(merchant_id: str, **kwargs) -> list[Order]:
q = " AND ".join(
[
f"{field[0]} = :{field[0]}"
@@ -648,7 +616,7 @@ async def get_orders(merchant_id: str, **kwargs) -> List[Order]:
rows: list[dict] = await db.fetchall(
f"""
SELECT * FROM nostrmarket.orders
- WHERE merchant_id = :merchant_id {('AND ' + q) if q else ''}
+ WHERE merchant_id = :merchant_id {q}
ORDER BY event_created_at DESC
""",
values,
@@ -658,7 +626,7 @@ async def get_orders(merchant_id: str, **kwargs) -> List[Order]:
async def get_orders_for_stall(
merchant_id: str, stall_id: str, **kwargs
-) -> List[Order]:
+) -> list[Order]:
q = " AND ".join(
[
f"{field[0]} = :{field[0]}"
@@ -672,11 +640,10 @@ async def get_orders_for_stall(
continue
values[field[0]] = field[1]
- q_clause = f"AND {q}" if q else ""
rows: list[dict] = await db.fetchall(
f"""
SELECT * FROM nostrmarket.orders
- WHERE merchant_id = :merchant_id AND stall_id = :stall_id {q_clause}
+ WHERE merchant_id = :merchant_id AND stall_id = :stall_id {q}
ORDER BY time DESC
""",
values,
@@ -684,7 +651,7 @@ async def get_orders_for_stall(
return [Order.from_row(row) for row in rows]
-async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Order]:
+async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | None:
q = ", ".join(
[
f"{field[0]} = :{field[0]}"
@@ -708,7 +675,7 @@ async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Or
return await get_order(merchant_id, order_id)
-async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]:
+async def update_order_paid_status(order_id: str, paid: bool) -> Order | None:
await db.execute(
"UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id",
{"paid": paid, "id": order_id},
@@ -722,7 +689,7 @@ async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]
async def update_order_shipped_status(
merchant_id: str, order_id: str, shipped: bool
-) -> Optional[Order]:
+) -> Order | None:
await db.execute(
"""
UPDATE nostrmarket.orders
@@ -786,7 +753,7 @@ async def create_direct_message(
return msg
-async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMessage]:
+async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | None:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.direct_messages
@@ -802,7 +769,7 @@ async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMes
async def get_direct_message_by_event_id(
merchant_id: str, event_id: str
-) -> Optional[DirectMessage]:
+) -> DirectMessage | None:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.direct_messages
@@ -816,7 +783,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
@@ -828,7 +795,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
@@ -889,7 +856,7 @@ async def create_customer(merchant_id: str, data: Customer) -> Customer:
return customer
-async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]:
+async def get_customer(merchant_id: str, public_key: str) -> Customer | None:
row: dict = await db.fetchone(
"""
SELECT * FROM nostrmarket.customers
@@ -903,7 +870,7 @@ async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]:
return Customer.from_row(row) if row else None
-async def get_customers(merchant_id: str) -> List[Customer]:
+async def get_customers(merchant_id: str) -> list[Customer]:
rows: list[dict] = await db.fetchall(
"SELECT * FROM nostrmarket.customers WHERE merchant_id = :merchant_id",
{"merchant_id": merchant_id},
@@ -911,7 +878,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 b4fc5d2..6446ca7 100644
--- a/description.md
+++ b/description.md
@@ -1,10 +1,12 @@
-Buy and sell products over Nostr using the NIP-15 marketplace protocol.
+> IMPORTANT: Nostr market needs the nostr-client extension installed.
-Its functions include:
+Buy and sell things over Nostr, using NIP15 https://github.com/nostr-protocol/nips/blob/master/15.md
-- 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)
+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.
-A decentralized commerce solution for merchants and buyers who want to trade goods and services over Nostr with end-to-end encrypted communication.
+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.
diff --git a/helpers.py b/helpers.py
index 1bc81b6..dcc0f06 100644
--- a/helpers.py
+++ b/helpers.py
@@ -1,9 +1,71 @@
-from bech32 import bech32_decode, convertbits
+import base64
+import secrets
-# NOTE (aiolabs/nostrmarket#5): `sign_message_hash` is gone. All merchant
-# signing routes through the lnbits `NostrSigner` ABC via
-# `services._resolve_merchant_signer(merchant)`. The nsec lives in the
-# bunker, never in this process.
+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:
+ privkey = coincurve.PrivateKey(bytes.fromhex(private_key))
+ sig = privkey.sign_schnorr(hash_)
+ 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:
diff --git a/migrations_fork.py b/migrations_fork.py
deleted file mode 100644
index 22bf38e..0000000
--- a/migrations_fork.py
+++ /dev/null
@@ -1,77 +0,0 @@
-"""
-aiolabs fork-migrations for nostrmarket (companion to upstream
-`migrations.py`).
-
-Per the aiolabs/lnbits#8 fork-migrations pattern: every aio-only
-schema delta lives in this single squashed function so we never
-introduce conflicts in `migrations.py` (which stays byte-identical to
-upstream and rebases cleanly).
-
-The function is loaded by lnbits's patched `migrate_extension_database()`
-under the `nostrmarket_fork` namespace in core `dbversions`, with the
-following invariants:
- - Every ALTER must be idempotent (use `_alter_drop_column_safe`-style
- wrappers or `IF NOT EXISTS` / `IF EXISTS` predicates) so re-runs
- are no-ops on already-migrated installs.
- - Schema changes here MUST NOT depend on the version of upstream's
- `migrations.py` they're running against — upstream rebases must
- not require this file to be edited.
-
-See `aiolabs/lnbits#8` for the pattern + `aiolabs/lnbits/core/services/
-signer_migration.py` for the prior art on `_alter_*_safe` helpers.
-"""
-
-from loguru import logger
-
-
-async def _drop_column_safe(db, table: str, column: str) -> None:
- """SQLite-safe drop-column. Newer SQLite (3.35+) supports
- `ALTER TABLE … DROP COLUMN`; older versions need the classic
- create-new-table + copy + swap dance. Postgres handles
- `ALTER TABLE … DROP COLUMN IF EXISTS` natively.
-
- Idempotent: catches "no such column" + "column does not exist"
- so re-runs are no-ops.
- """
- try:
- # Postgres path (supports IF EXISTS natively); also works on
- # SQLite ≥ 3.35.
- await db.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS {column};")
- return
- except Exception as exc:
- # SQLite < 3.35 doesn't support IF EXISTS; fall through to the
- # bare DROP COLUMN attempt + swallow the not-found case.
- msg = str(exc).lower()
- if "syntax" not in msg and "if exists" not in msg:
- # Something other than the IF-EXISTS unsupported case; surface.
- raise
-
- try:
- await db.execute(f"ALTER TABLE {table} DROP COLUMN {column};")
- except Exception as exc:
- msg = str(exc).lower()
- if "no such column" in msg or "does not exist" in msg:
- # Already dropped; idempotent skip.
- return
- raise
-
-
-async def m001_aio_drop_merchant_private_key(db):
- """Drop the legacy `nostrmarket.merchants.private_key` column.
-
- Per aiolabs/nostrmarket#5, the merchant's signing identity is owned
- by the lnbits-side account: signing routes through
- `resolve_signer(account).sign_event(...)` (which dispatches to
- `RemoteBunkerSigner` post-aiolabs/lnbits#18 phase 2.x). The nsec
- never lives in this extension's storage. Dropping the column makes
- that contract enforced at the schema level rather than relying on
- "nobody writes to it anymore."
-
- Idempotent: re-runs no-op via `_drop_column_safe`.
- """
- logger.info(
- "[NOSTRMARKET fork] m001: dropping merchants.private_key "
- "(aiolabs/nostrmarket#5 — signing routes through lnbits NostrSigner)"
- )
- await _drop_column_safe(db, "nostrmarket.merchants", "private_key")
- logger.info("[NOSTRMARKET fork] m001: done")
diff --git a/misc-docs/ORDER-DISCOVERY-ANALYSIS.md b/misc-docs/ORDER-DISCOVERY-ANALYSIS.md
deleted file mode 100644
index de393e2..0000000
--- a/misc-docs/ORDER-DISCOVERY-ANALYSIS.md
+++ /dev/null
@@ -1,320 +0,0 @@
-# Nostrmarket Order Discovery Analysis
-
-## Executive Summary
-
-This document analyzes the order discovery mechanism in the Nostrmarket extension and identifies why merchants must manually refresh to see new orders instead of receiving them automatically through persistent subscriptions.
-
----
-
-## Current Architecture
-
-### Two Subscription Systems
-
-The Nostrmarket extension implements two distinct subscription mechanisms for receiving Nostr events:
-
-#### 1. **Persistent Subscriptions (Background Task)**
-
-**Purpose**: Continuous monitoring for new orders, products, and merchant events
-
-**Implementation**:
-
-- Runs via `wait_for_nostr_events()` background task
-- Initiated on extension startup (15-second delay)
-- Creates subscription ID: `nostrmarket-{hash}`
-- Monitors all merchant public keys continuously
-
-**Code Location**: `/nostrmarket/tasks.py:37-49`
-
-```python
-async def wait_for_nostr_events(nostr_client: NostrClient):
- while True:
- try:
- await subscribe_to_all_merchants()
- while True:
- message = await nostr_client.get_event()
- await process_nostr_message(message)
-```
-
-**Subscription Filters**:
-
-- Direct messages (kind 4) - for orders
-- Stall events (kind 30017)
-- Product events (kind 30018)
-- Profile updates (kind 0)
-
-#### 2. **Temporary Subscriptions (Manual Refresh)**
-
-**Purpose**: Catch up on missed events when merchant clicks "Refresh from Nostr"
-
-**Implementation**:
-
-- Duration: 10 seconds only
-- Triggered by user action
-- Creates subscription ID: `merchant-{hash}`
-- Fetches ALL events from time=0
-
-**Code Location**: `/nostrmarket/nostr/nostr_client.py:100-120`
-
-```python
-async def merchant_temp_subscription(self, pk, duration=10):
- dm_filters = self._filters_for_direct_messages([pk], 0)
- # ... creates filters with time=0 (all history)
- await self.send_req_queue.put(["REQ", subscription_id] + merchant_filters)
- asyncio.create_task(unsubscribe_with_delay(subscription_id, duration))
-```
-
----
-
-## Problem Identification
-
-### Why Manual Refresh is Required
-
-#### **Issue 1: Timing Window Problem**
-
-The persistent subscription uses timestamps from the last database update:
-
-```python
-async def subscribe_to_all_merchants():
- last_dm_time = await get_last_direct_messages_created_at()
- last_stall_time = await get_last_stall_update_time()
- last_prod_time = await get_last_product_update_time()
-
- await nostr_client.subscribe_merchants(
- public_keys, last_dm_time, last_stall_time, last_prod_time, 0
- )
-```
-
-**Problem**: Events that occur between:
-
-- The last database update time
-- When the subscription becomes active
- ...are potentially missed
-
-#### **Issue 2: Connection Stability**
-
-The WebSocket connection between components may be unstable:
-
-```
-[Nostrmarket] <--WebSocket--> [Nostrclient] <--WebSocket--> [Nostr Relays]
- Extension Extension (Global)
-```
-
-**Potential failure points**:
-
-1. Connection drops between nostrmarket → nostrclient
-2. Connection drops between nostrclient → relays
-3. Reconnection doesn't re-establish subscriptions
-
-#### **Issue 3: Subscription State Management**
-
-**Current behavior**:
-
-- Single persistent subscription per merchant
-- No automatic resubscription on failure
-- No heartbeat/keepalive mechanism
-- No verification that subscription is active
-
-#### **Issue 4: Event Processing Delays**
-
-The startup sequence has intentional delays:
-
-```python
-async def _subscribe_to_nostr_client():
- await asyncio.sleep(10) # Wait for nostrclient
- await nostr_client.run_forever()
-
-async def _wait_for_nostr_events():
- await asyncio.sleep(15) # Wait for extension init
- await wait_for_nostr_events(nostr_client)
-```
-
-**Problem**: Orders arriving during initialization are missed
-
----
-
-## Why Manual Refresh Works
-
-The temporary subscription succeeds because:
-
-1. **Fetches from time=0**: Gets ALL historical events
-2. **Fresh connection**: Creates new subscription request
-3. **Immediate processing**: No startup delays
-4. **Direct feedback**: User sees results immediately
-
-```python
-# Temporary subscription uses time=0 (all events)
-dm_filters = self._filters_for_direct_messages([pk], 0) # ← 0 means all time
-
-# Persistent subscription uses last update time
-dm_filters = self._filters_for_direct_messages(public_keys, dm_time) # ← can miss events
-```
-
----
-
-## Impact Analysis
-
-### User Experience Issues
-
-1. **Merchants miss orders** without manual refresh
-2. **No real-time notifications** for new orders
-3. **Uncertainty** about order status
-4. **Extra manual steps** required
-5. **Delayed order fulfillment**
-
-### Technical Implications
-
-1. **Not truly decentralized** - requires active monitoring
-2. **Scalability concerns** - manual refresh doesn't scale
-3. **Reliability issues** - depends on user action
-4. **Performance overhead** - fetching all events repeatedly
-
----
-
-## Recommended Solutions
-
-### Solution A: Enhanced Persistent Subscriptions
-
-**Implement redundant subscription mechanisms:**
-
-```python
-class EnhancedSubscriptionManager:
- def __init__(self):
- self.last_heartbeat = time.time()
- self.subscription_active = False
-
- async def maintain_subscription(self):
- while True:
- if not self.subscription_active or \
- time.time() - self.last_heartbeat > 30:
- await self.resubscribe_with_overlap()
- await asyncio.sleep(10)
-
- async def resubscribe_with_overlap(self):
- # Use timestamp with 5-minute overlap
- overlap_time = int(time.time()) - 300
- await subscribe_to_all_merchants(since=overlap_time)
-```
-
-### Solution B: Periodic Auto-Refresh
-
-**Add automatic temporary subscriptions:**
-
-```python
-async def auto_refresh_loop():
- while True:
- await asyncio.sleep(60) # Every minute
- merchants = await get_all_active_merchants()
- for merchant in merchants:
- await merchant_temp_subscription(merchant.pubkey, duration=5)
-```
-
-### Solution C: WebSocket Health Monitoring
-
-**Implement connection health checks:**
-
-```python
-class WebSocketHealthMonitor:
- async def check_connection_health(self):
- try:
- # Send ping to nostrclient
- response = await nostr_client.ping(timeout=5)
- if not response:
- await self.reconnect_and_resubscribe()
- except Exception:
- await self.reconnect_and_resubscribe()
-```
-
-### Solution D: Event Gap Detection
-
-**Detect and fill gaps in event sequence:**
-
-```python
-async def detect_event_gaps():
- # Check for gaps in event timestamps
- last_known = await get_last_event_time()
- current_time = int(time.time())
-
- if current_time - last_known > 60: # 1 minute gap
- # Perform temporary subscription to fill gap
- await fetch_missing_events(since=last_known)
-```
-
----
-
-## Implementation Priority
-
-### Phase 1: Quick Fixes (1-2 days)
-
-1. [DONE] Increase temp subscription duration (10s → 30s)
-2. [DONE] Add connection health logging
-3. [DONE] Reduce startup delays
-
-### Phase 2: Reliability (3-5 days)
-
-1. [TODO] Implement subscription heartbeat
-2. [TODO] Add automatic resubscription on failure
-3. [TODO] Create event gap detection
-
-### Phase 3: Full Solution (1-2 weeks)
-
-1. [TODO] WebSocket connection monitoring
-2. [TODO] Redundant subscription system
-3. [TODO] Real-time order notifications
-4. [TODO] Event deduplication logic
-
----
-
-## Testing Recommendations
-
-### Test Scenarios
-
-1. **Order during startup**: Send order within 15 seconds of server start
-2. **Long-running test**: Keep server running for 24 hours, send periodic orders
-3. **Connection interruption**: Disconnect nostrclient, send order, reconnect
-4. **High volume**: Send 100 orders rapidly
-5. **Network latency**: Add artificial delay between components
-
-### Monitoring Metrics
-
-- Time between order sent → order discovered
-- Percentage of orders requiring manual refresh
-- WebSocket connection uptime
-- Subscription success rate
-- Event processing latency
-
----
-
-## Conclusion
-
-The current order discovery system relies on manual refresh due to:
-
-1. **Timing gaps** in persistent subscriptions
-2. **Connection stability** issues
-3. **Lack of redundancy** in subscription management
-4. **No automatic recovery** mechanisms
-
-While the temporary subscription (manual refresh) provides a workaround, a proper solution requires implementing connection monitoring, subscription health checks, and automatic gap-filling mechanisms to ensure reliable real-time order discovery.
-
----
-
-## Appendix: Code References
-
-### Key Files
-
-- `/nostrmarket/tasks.py` - Background task management
-- `/nostrmarket/nostr/nostr_client.py` - Nostr client implementation
-- `/nostrmarket/services.py` - Order processing logic
-- `/nostrmarket/views_api.py` - API endpoints for refresh
-
-### Relevant Functions
-
-- `wait_for_nostr_events()` - Main event loop
-- `subscribe_to_all_merchants()` - Persistent subscription
-- `merchant_temp_subscription()` - Manual refresh
-- `process_nostr_message()` - Event processing
-
----
-
-_Document prepared: January 2025_
-_Analysis based on: Nostrmarket v1.0_
-_Status: Active Investigation_
diff --git a/models.py b/models.py
index 6a4ae3b..f1af073 100644
--- a/models.py
+++ b/models.py
@@ -2,11 +2,17 @@ import json
import time
from abc import abstractmethod
from enum import Enum
-from typing import Any, List, Optional, Tuple
+from typing import Any
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
from pydantic import BaseModel
+from .helpers import (
+ decrypt_message,
+ encrypt_message,
+ get_shared_secret,
+ sign_message_hash,
+)
from .nostr.event import NostrEvent
######################################## NOSTR ########################################
@@ -26,47 +32,52 @@ class Nostrable:
class MerchantProfile(BaseModel):
- name: Optional[str] = None
- display_name: Optional[str] = None
- about: Optional[str] = None
- picture: Optional[str] = None
- banner: Optional[str] = None
- website: Optional[str] = None
- nip05: Optional[str] = None
- lud16: Optional[str] = None
+ name: str | None = None
+ about: str | None = None
+ picture: str | None = None
class MerchantConfig(MerchantProfile):
- event_id: Optional[str] = None
+ event_id: str | None = None
sync_from_nostr: bool = False
- # TODO: switched to True for AIO demo; determine if we leave this as True
- active: bool = True
- restore_in_progress: Optional[bool] = False
- # Set at runtime (not persisted) when account keypair != merchant keypair
- key_mismatch: Optional[bool] = False
-
-
-class CreateMerchantRequest(BaseModel):
- config: MerchantConfig = MerchantConfig()
+ active: bool = False
+ restore_in_progress: bool | None = False
class PartialMerchant(BaseModel):
+ private_key: str
public_key: str
config: MerchantConfig = MerchantConfig()
class Merchant(PartialMerchant, Nostrable):
id: str
- user_id: str
- time: Optional[int] = 0
+ time: int | None = 0
- # NOTE (aiolabs/nostrmarket#5): no `sign_hash` / `encrypt_message` /
- # `decrypt_message` methods on Merchant anymore. Signing + NIP-44 crypto
- # for a merchant goes through the lnbits `NostrSigner` abstraction
- # (`resolve_signer(account)`); merchant is now pure metadata pointing
- # at its owning account via `user_id`. The bunker (`RemoteBunkerSigner`)
- # holds the merchant's nsec — lnbits never has it server-side.
- # See `services._resolve_merchant_signer()` for the resolution helper.
+ 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":
@@ -75,23 +86,11 @@ class Merchant(PartialMerchant, Nostrable):
return merchant
def to_nostr_event(self, pubkey: str) -> NostrEvent:
- content: dict[str, str] = {}
- if self.config.name:
- content["name"] = self.config.name
- if self.config.display_name:
- content["display_name"] = self.config.display_name
- if self.config.about:
- content["about"] = self.config.about
- if self.config.picture:
- content["picture"] = self.config.picture
- if self.config.banner:
- content["banner"] = self.config.banner
- if self.config.website:
- content["website"] = self.config.website
- if self.config.nip05:
- content["nip05"] = self.config.nip05
- if self.config.lud16:
- content["lud16"] = self.config.lud16
+ content = {
+ "name": self.config.name,
+ "about": self.config.about,
+ "picture": self.config.picture,
+ }
event = NostrEvent(
pubkey=pubkey,
created_at=round(time.time()),
@@ -123,11 +122,11 @@ class Merchant(PartialMerchant, Nostrable):
######################################## ZONES ########################################
class Zone(BaseModel):
- id: Optional[str] = None
- name: Optional[str] = None
+ id: str | None = None
+ name: str | None = None
currency: str
cost: float
- countries: List[str] = []
+ countries: list[str] = []
@classmethod
def from_row(cls, row: dict) -> "Zone":
@@ -140,22 +139,22 @@ class Zone(BaseModel):
class StallConfig(BaseModel):
- image_url: Optional[str] = None
- description: Optional[str] = None
+ image_url: str | None = None
+ description: str | None = None
class Stall(BaseModel, Nostrable):
- id: Optional[str] = None
+ id: str | None = None
wallet: str
name: str
currency: str = "sat"
- shipping_zones: List[Zone] = []
+ shipping_zones: list[Zone] = []
config: StallConfig = StallConfig()
pending: bool = False
"""Last published nostr event for this Stall"""
- event_id: Optional[str] = None
- event_created_at: Optional[int] = None
+ event_id: str | None = None
+ event_created_at: int | None = None
def validate_stall(self):
for z in self.shipping_zones:
@@ -213,19 +212,19 @@ class ProductShippingCost(BaseModel):
class ProductConfig(BaseModel):
- description: Optional[str] = None
- currency: Optional[str] = None
- use_autoreply: Optional[bool] = False
- autoreply_message: Optional[str] = None
- shipping: List[ProductShippingCost] = []
+ description: str | None = None
+ currency: str | None = None
+ use_autoreply: bool | None = False
+ autoreply_message: str | None = None
+ shipping: list[ProductShippingCost] = []
class Product(BaseModel, Nostrable):
- id: Optional[str] = None
+ id: str | None = None
stall_id: str
name: str
- categories: List[str] = []
- images: List[str] = []
+ categories: list[str] = []
+ images: list[str] = []
price: float
quantity: int
active: bool = True
@@ -233,8 +232,8 @@ class Product(BaseModel, Nostrable):
config: ProductConfig = ProductConfig()
"""Last published nostr event for this Product"""
- event_id: Optional[str] = None
- event_created_at: Optional[int] = None
+ event_id: str | None = None
+ event_created_at: int | None = None
def to_nostr_event(self, pubkey: str) -> NostrEvent:
content = {
@@ -291,7 +290,7 @@ class ProductOverview(BaseModel):
id: str
name: str
price: float
- product_shipping_cost: Optional[float] = None
+ product_shipping_cost: float | None = None
@classmethod
def from_product(cls, p: Product) -> "ProductOverview":
@@ -308,21 +307,21 @@ class OrderItem(BaseModel):
class OrderContact(BaseModel):
- nostr: Optional[str] = None
- phone: Optional[str] = None
- email: Optional[str] = None
+ nostr: str | None = None
+ phone: str | None = None
+ email: str | None = None
class OrderExtra(BaseModel):
- products: List[ProductOverview]
+ products: list[ProductOverview]
currency: str
btc_price: str
shipping_cost: float = 0
shipping_cost_sat: float = 0
- fail_message: Optional[str] = None
+ fail_message: str | None = None
@classmethod
- async def from_products(cls, products: List[Product]):
+ async def from_products(cls, products: list[Product]):
currency = products[0].config.currency if len(products) else "sat"
exchange_rate = (
await btc_price(currency) if currency and currency != "sat" else 1
@@ -338,19 +337,19 @@ class OrderExtra(BaseModel):
class PartialOrder(BaseModel):
id: str
- event_id: Optional[str] = None
- event_created_at: Optional[int] = None
+ event_id: str | None = None
+ event_created_at: int | None = None
public_key: str
merchant_public_key: str
shipping_id: str
- items: List[OrderItem]
- contact: Optional[OrderContact] = None
- address: Optional[str] = None
+ items: list[OrderItem]
+ contact: OrderContact | None = None
+ address: str | None = None
def validate_order(self):
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
- def validate_order_items(self, product_list: List[Product]):
+ def validate_order_items(self, product_list: list[Product]):
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
assert (
len(product_list) != 0
@@ -371,8 +370,8 @@ class PartialOrder(BaseModel):
)
async def costs_in_sats(
- self, products: List[Product], shipping_id: str, stall_shipping_cost: float
- ) -> Tuple[float, float]:
+ self, products: list[Product], shipping_id: str, stall_shipping_cost: float
+ ) -> tuple[float, float]:
product_prices = {}
for p in products:
product_shipping_cost = next(
@@ -401,7 +400,7 @@ class PartialOrder(BaseModel):
return product_cost, stall_shipping_cost
def receipt(
- self, products: List[Product], shipping_id: str, stall_shipping_cost: float
+ self, products: list[Product], shipping_id: str, stall_shipping_cost: float
) -> str:
if len(products) == 0:
return "[No Products]"
@@ -450,7 +449,7 @@ class Order(PartialOrder):
total: float
paid: bool = False
shipped: bool = False
- time: Optional[int] = None
+ time: int | None = None
extra: OrderExtra
@classmethod
@@ -464,14 +463,14 @@ class Order(PartialOrder):
class OrderStatusUpdate(BaseModel):
id: str
- message: Optional[str] = None
- paid: Optional[bool] = False
- shipped: Optional[bool] = None
+ message: str | None = None
+ paid: bool | None = False
+ shipped: bool | None = None
class OrderReissue(BaseModel):
id: str
- shipping_id: Optional[str] = None
+ shipping_id: str | None = None
class PaymentOption(BaseModel):
@@ -481,8 +480,8 @@ class PaymentOption(BaseModel):
class PaymentRequest(BaseModel):
id: str
- message: Optional[str] = None
- payment_options: List[PaymentOption]
+ message: str | None = None
+ payment_options: list[PaymentOption]
######################################## MESSAGE #######################################
@@ -498,16 +497,16 @@ class DirectMessageType(Enum):
class PartialDirectMessage(BaseModel):
- event_id: Optional[str] = None
- event_created_at: Optional[int] = None
+ event_id: str | None = None
+ event_created_at: int | None = None
message: str
public_key: str
type: int = DirectMessageType.PLAIN_TEXT.value
incoming: bool = False
- time: Optional[int] = None
+ time: int | None = None
@classmethod
- def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]:
+ def parse_message(cls, msg) -> tuple[DirectMessageType, Any | None]:
try:
msg_json = json.loads(msg)
if "type" in msg_json:
@@ -530,15 +529,15 @@ class DirectMessage(PartialDirectMessage):
class CustomerProfile(BaseModel):
- name: Optional[str] = None
- about: Optional[str] = None
+ name: str | None = None
+ about: str | None = None
class Customer(BaseModel):
merchant_id: str
public_key: str
- event_created_at: Optional[int] = None
- profile: Optional[CustomerProfile] = None
+ event_created_at: int | None = None
+ profile: CustomerProfile | None = None
unread_messages: int = 0
@classmethod
diff --git a/nostr/nip44.py b/nostr/nip44.py
deleted file mode 100644
index 908ad8a..0000000
--- a/nostr/nip44.py
+++ /dev/null
@@ -1,180 +0,0 @@
-"""
-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
deleted file mode 100644
index 19cc718..0000000
--- a/nostr/nip59.py
+++ /dev/null
@@ -1,231 +0,0 @@
-"""
-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
-
-## Bunker integration (aiolabs/nostrmarket#5)
-
-Merchant-identity layers (rumor's sender-pubkey + seal's encryption +
-seal's signature) route through the lnbits `NostrSigner` abstraction
-so the merchant's nsec stays in the bunker — never reaches this
-process. Specifically:
-
-- `create_seal` is async; takes a `sender_signer` instead of a
- plaintext nsec. The seal's NIP-44 encrypt + Schnorr sign happen
- via `await sender_signer.nip44_encrypt(...)` +
- `await sender_signer.sign_event(...)` over the NIP-46 channel.
-- `unwrap_gift_wrap` + `unseal` are async; take a `recipient_signer`
- and call `await recipient_signer.nip44_decrypt(...)` for each layer.
-
-The **ephemeral keypair layer** (`create_gift_wrap`) stays synchronous
-+ local: the ephemeral nsec exists for the lifetime of one wrap and
-provides no merchant-identity capability, so there's no reason to
-involve the bunker. Generating it locally avoids one round-trip per
-DM.
-"""
-
-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_local(event: NostrEvent, private_key_hex: str) -> NostrEvent:
- """Compute event id and sign it locally with a privkey held in this
- process. Used only for the ephemeral-keypair layer (gift wrap outer);
- merchant-identity sign goes through the signer ABC instead."""
- 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
-
-
-async def create_seal(
- rumor: NostrEvent,
- sender_signer,
- recipient_pubkey: str,
-) -> NostrEvent:
- """
- Create a kind 13 seal: encrypts the rumor for the recipient.
- Signed by the sender. Tags are always empty.
-
- Both crypto operations (NIP-44 encrypt + Schnorr sign) route
- through the sender's `NostrSigner` (`sender_signer`) — the
- plaintext nsec is never observable in this process.
- """
- encrypted_rumor = await sender_signer.nip44_encrypt(
- rumor.stringify(), recipient_pubkey
- )
-
- seal = NostrEvent(
- pubkey=sender_signer.pubkey,
- created_at=_random_past_timestamp(),
- kind=13,
- tags=[],
- content=encrypted_rumor,
- )
- # The signer fills id + sig (computed bunker-side).
- signed = await sender_signer.sign_event(
- {
- "pubkey": seal.pubkey,
- "created_at": seal.created_at,
- "kind": seal.kind,
- "tags": seal.tags,
- "content": seal.content,
- }
- )
- seal.id = signed["id"]
- seal.sig = signed["sig"]
- return seal
-
-
-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.
-
- Stays synchronous + local: the ephemeral nsec exists only for the
- lifetime of one wrap and provides no merchant-identity capability,
- so there's no point routing through the bunker (would add one NIP-46
- round-trip per DM with zero security benefit).
- """
- 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_local(wrap, ephemeral_privkey)
-
-
-async def unwrap_gift_wrap(
- gift_wrap: NostrEvent,
- recipient_signer,
-) -> NostrEvent:
- """
- Decrypt a kind 1059 gift wrap to reveal the inner seal.
- Routes NIP-44 decrypt through the recipient's signer abstraction
- so the recipient's nsec stays in the bunker.
- """
- seal_json = await recipient_signer.nip44_decrypt(
- gift_wrap.content, gift_wrap.pubkey
- )
- return NostrEvent(**json.loads(seal_json))
-
-
-async def unseal(
- seal: NostrEvent,
- recipient_signer,
-) -> NostrEvent:
- """
- Decrypt a kind 13 seal to reveal the inner rumor.
- Uses the recipient signer (their nsec stays in the bunker) and the
- seal's pubkey (the sender). Validates that the rumor's pubkey
- matches the seal's pubkey.
- """
- rumor_json = await recipient_signer.nip44_decrypt(seal.content, seal.pubkey)
- 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 ---
-
-
-async def wrap_message(
- content: str,
- sender_signer,
- 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.
-
- `sender_signer` is the sender merchant's `NostrSigner` (post-#5:
- always a `RemoteBunkerSigner`). The merchant's nsec never leaves
- the bunker.
- """
- rumor = create_rumor(sender_signer.pubkey, content, kind=kind, tags=tags)
- seal = await create_seal(rumor, sender_signer, recipient_pubkey)
- return create_gift_wrap(seal, recipient_pubkey)
-
-
-async def unwrap_message(
- gift_wrap: NostrEvent,
- recipient_signer,
-) -> NostrEvent:
- """
- Full unwrap pipeline: gift wrap → seal → rumor.
- Returns the rumor with sender pubkey and plaintext content.
-
- `recipient_signer` is the recipient merchant's `NostrSigner`. Both
- NIP-44 decrypt layers (gift wrap → seal, seal → rumor) route
- through the signer abstraction.
- """
- seal = await unwrap_gift_wrap(gift_wrap, recipient_signer)
- return await unseal(seal, recipient_signer)
diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py
index dbc410e..a611980 100644
--- a/nostr/nostr_client.py
+++ b/nostr/nostr_client.py
@@ -1,8 +1,6 @@
import asyncio
import json
-import time
from asyncio import Queue
-from collections import OrderedDict
from threading import Thread
from typing import Callable, List, Optional
@@ -14,8 +12,6 @@ from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash
from .event import NostrEvent
-MAX_SEEN_EVENTS = 1000
-
class NostrClient:
def __init__(self):
@@ -24,8 +20,6 @@ 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):
@@ -37,11 +31,9 @@ class NostrClient:
logger.debug(f"Connecting to websockets for 'nostrclient' extension...")
relay_endpoint = encrypt_internal_message("relay", urlsafe=True)
- ws_url = f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}"
-
on_open, on_message, on_error, on_close = self._ws_handlers()
ws = WebSocketApp(
- ws_url,
+ f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}",
on_message=on_message,
on_open=on_open,
on_close=on_close,
@@ -70,21 +62,10 @@ 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):
@@ -110,6 +91,10 @@ class NostrClient:
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
await self.send_req_queue.put(["REQ", self.subscription_id] + merchant_filters)
+ logger.debug(
+ f"Subscribing to events for: {len(public_keys)} keys. New subscription id: {self.subscription_id}"
+ )
+
async def merchant_temp_subscription(self, pk, duration=10):
dm_filters = self._filters_for_direct_messages([pk], 0)
stall_filters = self._filters_for_stall_events([pk], 0)
@@ -150,16 +135,13 @@ class NostrClient:
logger.debug(ex)
def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List:
- # 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).
- #
- # Do NOT apply `since` here. Per NIP-59, gift wraps use randomized past
- # timestamps (up to 2 days back) to defeat metadata correlation, so a
- # `since` derived from the latest DM in our DB will reject fresh wraps
- # whose randomized created_at is older than that window. Server-side
- # dedup + the client's is_duplicate_event() guard handle replays.
- gift_wrap_filter: dict = {"kinds": [1059], "#p": public_keys}
- return [gift_wrap_filter]
+ in_messages_filter = {"kinds": [4], "#p": public_keys}
+ out_messages_filter = {"kinds": [4], "authors": public_keys}
+ if since and since != 0:
+ in_messages_filter["since"] = since
+ out_messages_filter["since"] = since
+
+ return [in_messages_filter, out_messages_filter]
def _filters_for_stall_events(self, public_keys: List[str], since: int) -> List:
stall_filter = {"kinds": [30017], "authors": public_keys}
@@ -193,21 +175,16 @@ class NostrClient:
def _ws_handlers(self):
def on_open(_):
- logger.debug("[NOSTRMARKET DEBUG] ✅ Connected to 'nostrclient' websocket successfully")
+ logger.info("Connected to 'nostrclient' websocket")
def on_message(_, message):
- logger.debug(f"[NOSTRMARKET DEBUG] 📨 Received websocket message: {message[:200]}...")
- try:
- self.recieve_event_queue.put_nowait(message)
- logger.debug(f"[NOSTRMARKET DEBUG] 📤 Message queued successfully")
- except Exception as e:
- logger.error(f"[NOSTRMARKET] ❌ Failed to queue message: {e}")
+ self.recieve_event_queue.put_nowait(message)
def on_error(_, error):
- logger.warning(f"[NOSTRMARKET] ❌ Websocket error: {error}")
+ logger.warning(error)
def on_close(x, status_code, message):
- logger.warning(f"[NOSTRMARKET] 🔌 Websocket closed: {x}: '{status_code}' '{message}'")
+ logger.warning(f"Websocket closed: {x}: '{status_code}' '{message}'")
# force re-subscribe
self.recieve_event_queue.put_nowait(ValueError("Websocket close."))
diff --git a/services.py b/services.py
index 444a9d5..4039dbb 100644
--- a/services.py
+++ b/services.py
@@ -1,12 +1,9 @@
import asyncio
import json
-from typing import List, Optional, Tuple
-from lnbits.bolt11 import decode
-from lnbits.core.crud import get_account, get_wallet
+from bolt11 import decode
+from lnbits.core.crud import get_wallet
from lnbits.core.services import create_invoice, websocket_updater
-from lnbits.core.signers import resolve_signer
-from lnbits.core.signers.base import NostrSigner, SignerError
from loguru import logger
from . import nostr_client
@@ -14,11 +11,9 @@ 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,
@@ -46,7 +41,6 @@ from .models import (
DirectMessage,
DirectMessageType,
Merchant,
- MerchantConfig,
Nostrable,
Order,
OrderContact,
@@ -54,26 +48,22 @@ 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
-) -> Optional[PaymentRequest]:
+) -> PaymentRequest | None:
merchant = await get_merchant_by_pubkey(merchant_public_key)
assert merchant, "Cannot find merchant for order!"
- existing_order = await get_order(merchant.id, data.id)
- if existing_order:
+ if await get_order(merchant.id, data.id):
return None
if data.event_id and await get_order_by_event_id(merchant.id, data.event_id):
return None
@@ -83,24 +73,20 @@ async def create_new_order(
)
await create_order(merchant.id, order)
- payment_request = PaymentRequest(
+ return PaymentRequest(
id=data.id,
payment_options=[PaymentOption(type="ln", link=invoice)],
message=receipt,
)
- return payment_request
async def build_order_with_payment(
merchant_id: str, merchant_public_key: str, data: PartialOrder
):
-
products = await get_products_by_ids(
merchant_id, [p.product_id for p in data.items]
)
-
data.validate_order_items(products)
-
shipping_zone = await get_zone(merchant_id, data.shipping_id)
assert shipping_zone, f"Shipping zone not found for order '{data.id}'"
@@ -108,7 +94,6 @@ async def build_order_with_payment(
product_cost_sat, shipping_cost_sat = await data.costs_in_sats(
products, shipping_zone.id, shipping_zone.cost
)
-
receipt = data.receipt(products, shipping_zone.id, shipping_zone.cost)
wallet_id = await get_wallet_for_product(data.items[0].product_id)
@@ -119,13 +104,11 @@ async def build_order_with_payment(
merchant_id, product_ids, data.items
)
if not success:
- logger.error(f"[NOSTRMARKET] ❌ Product quantity check failed: {message}")
raise ValueError(message)
- total_amount_sat = round(product_cost_sat + shipping_cost_sat)
payment = await create_invoice(
wallet_id=wallet_id,
- amount=total_amount_sat,
+ amount=round(product_cost_sat + shipping_cost_sat),
memo=f"Order '{data.id}' for pubkey '{data.public_key}'",
extra={
"tag": "nostrmarket",
@@ -153,7 +136,7 @@ async def update_merchant_to_nostr(
merchant: Merchant, delete_merchant=False
) -> Merchant:
stalls = await get_stalls(merchant.id)
- event: Optional[NostrEvent] = None
+ event: NostrEvent | None = None
for stall in stalls:
assert stall.id
products = await get_products(merchant.id, stall.id)
@@ -166,180 +149,28 @@ async def update_merchant_to_nostr(
stall.event_id = event.id
stall.event_created_at = event.created_at
await update_stall(merchant.id, stall)
- # Always publish merchant profile (kind 0)
- event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant)
+ if delete_merchant:
+ # merchant profile updates not supported yet
+ event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant)
assert event
merchant.config.event_id = event.id
return merchant
-async def _resolve_merchant_signer(merchant: Merchant) -> NostrSigner:
- """Resolve the lnbits NostrSigner for a merchant's owning account.
-
- Post-#5 (aiolabs/nostrmarket#5), the merchant's nsec lives in the
- bunker via the account's `signer_config`. No fast-path or caching
- today — per-call lookup is fine for v1 throughput; if the events
- extension or DM hot path becomes contended, revisit with a
- process-local cache keyed on `merchant.user_id`.
-
- Raises `SignerError` if the account can't be found or its signer
- can't be resolved — callers should propagate, not silently skip,
- so misconfigured rows surface loudly.
- """
- account = await get_account(merchant.user_id)
- if account is None:
- raise SignerError(
- f"merchant {merchant.id[:8]} references missing account "
- f"{merchant.user_id[:8]} — can't resolve signer"
- )
- return resolve_signer(account)
-
-
async def sign_and_send_to_nostr(
merchant: Merchant, n: Nostrable, delete=False
) -> NostrEvent:
- """Sign + publish a Nostrable as the merchant's identity.
-
- Signing routes through the merchant's account `NostrSigner` (post-#5).
- The signer fills `id` + `sig` server-side (bunker for the
- `RemoteBunkerSigner` case) — this function builds the unsigned dict
- shape, hands it to the signer, and copies the result back onto the
- `NostrEvent` instance for the publisher.
- """
event = (
n.to_nostr_delete_event(merchant.public_key)
if delete
else n.to_nostr_event(merchant.public_key)
)
-
- signer = await _resolve_merchant_signer(merchant)
- signed = await signer.sign_event(
- {
- "pubkey": event.pubkey,
- "created_at": event.created_at,
- "kind": event.kind,
- "tags": event.tags,
- "content": event.content,
- }
- )
- event.id = signed["id"]
- event.sig = signed["sig"]
+ event.sig = merchant.sign_hash(bytes.fromhex(event.id))
await nostr_client.publish_nostr_event(event)
return event
-async def provision_merchant(
- user_id: str,
- wallet_id: str,
- public_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.
-
- Post-aiolabs/nostrmarket#5: no `private_key` argument. The merchant
- identity IS the lnbits account's identity (`public_key` parameter
- must equal `account.pubkey` for the same `user_id`); signing routes
- through the account's `NostrSigner` (`RemoteBunkerSigner` in the
- target deployment). The merchant nsec lives in the bunker, never
- server-side.
-
- 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(
- 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)
-
- raw_owner_name = display_name or "My"
- owner_name = raw_owner_name[:1].upper() + raw_owner_name[1:]
- default_stall = Stall(
- wallet=wallet_id,
- name=f"{owner_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.
- #
- # Fire-and-forget: `nostr_client.publish_nostr_event` has no per-relay
- # deadline and will block indefinitely if every configured relay is
- # unreachable (cf. aiolabs/nostrmarket#7). When `provision_merchant`
- # is called from the eager signup hook (lnbits/core/services/users.py
- # ::_create_default_merchant, aiolabs/lnbits#46), inline-awaiting that
- # publish hangs the uvicorn worker on `POST /auth/register` forever.
- # The DB rows we just wrote are sufficient to serve the wallet UI;
- # the stall event_id gets backfilled when the publish completes (or
- # stays NULL until a later resubscribe-driven republish lands it).
- asyncio.create_task(
- _publish_default_stall_background(merchant.id, merchant, default_stall)
- )
-
- return merchant
-
-
-# Generous bound: signing through the bunker can take 1–2 s on a cold
-# session, plus the relay publish itself. 30 s is well over both, and
-# the cap matters only when the relay set is unreachable.
-STALL_PUBLISH_TIMEOUT_S = 30.0
-
-
-async def _publish_default_stall_background(
- merchant_id: str, merchant: Merchant, default_stall: Stall
-) -> None:
- """Background helper for `provision_merchant`'s default-stall publish.
-
- Bounded by `STALL_PUBLISH_TIMEOUT_S` so even a permanently-unreachable
- relay set doesn't pin an asyncio task forever. Errors and timeouts are
- logged at warning — never raised, since the caller scheduled-and-forgot.
- """
- try:
- stall_event = await asyncio.wait_for(
- sign_and_send_to_nostr(merchant, default_stall),
- timeout=STALL_PUBLISH_TIMEOUT_S,
- )
- default_stall.event_id = stall_event.id
- await update_stall(merchant_id, default_stall)
- except asyncio.TimeoutError:
- logger.warning(
- f"[NOSTRMARKET] Default stall publish for merchant "
- f"{merchant_id} timed out after {STALL_PUBLISH_TIMEOUT_S}s; "
- f"event_id stays NULL until a later republish lands it"
- )
- except Exception as ex:
- logger.warning(
- f"[NOSTRMARKET] Failed to publish default stall for "
- f"merchant {merchant_id}: {ex}"
- )
-
-
async def handle_order_paid(order_id: str, merchant_pubkey: str):
try:
order = await update_order_paid_status(order_id, True)
@@ -390,7 +221,7 @@ async def notify_client_of_order_status(
async def update_products_for_order(
merchant: Merchant, order: Order
-) -> Tuple[bool, str]:
+) -> tuple[bool, str]:
product_ids = [i.product_id for i in order.items]
success, products, message = await compute_products_new_quantity(
merchant.id, product_ids, order.items
@@ -431,37 +262,19 @@ async def send_dm(
other_pubkey: str,
type_: int,
dm_content: str,
-) -> DirectMessage:
- # Post-#5: nsec stays in the bunker; both the to-recipient wrap and
- # the to-self archival wrap route their seal-layer crypto through
- # the merchant's NostrSigner.
- signer = await _resolve_merchant_signer(merchant)
-
- # Wrap message to recipient via NIP-59 gift wrap
- gift_wrap = await wrap_message(
- dm_content,
- signer,
- other_pubkey,
- )
+):
+ dm_event = merchant.build_dm_event(dm_content, other_pubkey)
dm = PartialDirectMessage(
- event_id=gift_wrap.id,
- event_created_at=gift_wrap.created_at,
+ event_id=dm_event.id,
+ event_created_at=dm_event.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(gift_wrap)
-
- # Also wrap a copy to self for archival
- self_wrap = await wrap_message(
- dm_content,
- signer,
- merchant.public_key,
- )
- await nostr_client.publish_nostr_event(self_wrap)
+ await nostr_client.publish_nostr_event(dm_event)
await websocket_updater(
merchant.id,
@@ -474,13 +287,11 @@ 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(
@@ -503,38 +314,23 @@ async def compute_products_new_quantity(
async def process_nostr_message(msg: str):
try:
- parsed_msg = json.loads(msg)
- type_, *rest = parsed_msg
-
+ type_, *rest = json.loads(msg)
if type_.upper() == "EVENT":
- if len(rest) < 2:
- logger.warning(f"[NOSTRMARKET] ⚠️ EVENT message missing data: {rest}")
- return
_, event = rest
event = NostrEvent(**event)
-
- # 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 == 1059:
- await _handle_gift_wrap(event)
+ elif event.kind == 4:
+ await _handle_nip04_message(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.error(f"[NOSTRMARKET] ❌ Error processing nostr message: {ex}")
- logger.error(f"[NOSTRMARKET] 📄 Raw message that failed: {msg}")
+ logger.debug(ex)
async def create_or_update_order_from_dm(
@@ -615,42 +411,29 @@ async def extract_customer_order_from_dm(
return order
-async def _handle_gift_wrap(event: NostrEvent):
- """Handle an incoming kind 1059 gift wrap event (NIP-59/NIP-17)."""
+async def _handle_nip04_message(event: NostrEvent):
+ merchant_public_key = event.pubkey
+ merchant = await get_merchant_by_pubkey(merchant_public_key)
- p_tags = event.tag_values("p")
- if not 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:
- logger.warning(
- f"[NOSTRMARKET] ⚠️ No merchant found for gift wrap recipient: {recipient_pubkey}"
+ 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]
)
- return
-
- try:
- recipient_signer = await _resolve_merchant_signer(merchant)
- rumor = await unwrap_message(event, recipient_signer)
- 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)
+ await _handle_outgoing_dms(event, merchant, clear_text_msg)
+ elif event.has_tag_value("p", merchant_public_key):
+ clear_text_msg = merchant.decrypt_message(event.content, event.pubkey)
+ await _handle_incoming_dms(event, merchant, clear_text_msg)
+ else:
+ logger.warning(f"Bad NIP04 event: '{event.id}'")
async def _handle_incoming_dms(
@@ -700,18 +483,17 @@ async def _handle_outgoing_dms(
async def _handle_incoming_structured_dm(
merchant: Merchant, dm: DirectMessage, json_data: dict
-) -> Tuple[DirectMessageType, Optional[str]]:
+) -> tuple[DirectMessageType, str | None]:
try:
if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active:
json_resp = await _handle_new_order(
merchant.id, merchant.public_key, dm, json_data
)
+
return DirectMessageType.PAYMENT_REQUEST, json_resp
- else:
- logger.info(f"[NOSTRMARKET] Skipping order processing - type: {dm.type}, expected: {DirectMessageType.CUSTOMER_ORDER.value}, merchant_active: {merchant.config.active}")
except Exception as ex:
- logger.error(f"[NOSTRMARKET] Error in _handle_incoming_structured_dm: {ex}")
+ logger.warning(ex)
return DirectMessageType.PLAIN_TEXT, None
@@ -750,21 +532,16 @@ async def _persist_dm(
async def reply_to_structured_dm(
merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str
):
- signer = await _resolve_merchant_signer(merchant)
- gift_wrap = await wrap_message(
- dm_reply,
- signer,
- customer_pubkey,
- )
+ dm_event = merchant.build_dm_event(dm_reply, customer_pubkey)
dm = PartialDirectMessage(
- event_id=gift_wrap.id,
- event_created_at=gift_wrap.created_at,
+ event_id=dm_event.id,
+ event_created_at=dm_event.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(gift_wrap)
+ await nostr_client.publish_nostr_event(dm_event)
await websocket_updater(
merchant.id,
@@ -797,31 +574,9 @@ async def _handle_new_order(
wallet = await get_wallet(wallet_id)
assert wallet, f"Cannot find wallet for product id: {first_product_id}"
-
payment_req = await create_new_order(merchant_public_key, partial_order)
-
- if payment_req is None:
- # Return existing order data instead of creating a failed order
- existing_order = await get_order(merchant_id, partial_order.id)
- if existing_order and existing_order.invoice_id != "None":
- # Order exists with invoice, return existing payment request
- duplicate_response = json.dumps({
- "type": DirectMessageType.PAYMENT_REQUEST.value,
- "id": existing_order.id,
- "message": "Order already received and processed",
- "payment_options": []
- }, separators=(",", ":"), ensure_ascii=False)
- return duplicate_response
- else:
- # Order exists but no invoice, skip processing
- logger.info(f"[NOSTRMARKET] Order exists but no invoice, returning empty string")
- return ""
-
except Exception as e:
- logger.error(f"[NOSTRMARKET] Error creating order: {e}")
- logger.error(f"[NOSTRMARKET] Order data: {json_data}")
- logger.error(f"[NOSTRMARKET] Exception type: {type(e).__name__}")
- logger.error(f"[NOSTRMARKET] Exception details: {str(e)}")
+ logger.debug(e)
payment_req = await create_new_failed_order(
merchant_id,
merchant_public_key,
@@ -829,17 +584,12 @@ async def _handle_new_order(
json_data,
"Order received, but cannot be processed. Please contact merchant.",
)
-
- if not payment_req:
- logger.error(f"[NOSTRMARKET] No payment request returned for order: {partial_order.id}")
- return ""
-
+ assert payment_req
response = {
"type": DirectMessageType.PAYMENT_REQUEST.value,
**payment_req.dict(),
}
- response_json = json.dumps(response, separators=(",", ":"), ensure_ascii=False)
- return response_json
+ return json.dumps(response, separators=(",", ":"), ensure_ascii=False)
async def create_new_failed_order(
@@ -872,11 +622,8 @@ async def subscribe_to_all_merchants():
last_stall_time = await get_last_stall_update_time()
last_prod_time = await get_last_product_update_time()
- # Make dm_time more lenient by subtracting 5 minutes to avoid missing recent events
- lenient_dm_time = max(0, last_dm_time - 300) if last_dm_time > 0 else 0
-
await nostr_client.subscribe_merchants(
- public_keys, lenient_dm_time, last_stall_time, last_prod_time, 0
+ public_keys, last_dm_time, last_stall_time, last_prod_time, 0
)
diff --git a/static/components/edit-profile-dialog.js b/static/components/edit-profile-dialog.js
deleted file mode 100644
index 0736695..0000000
--- a/static/components/edit-profile-dialog.js
+++ /dev/null
@@ -1,91 +0,0 @@
-window.app.component('edit-profile-dialog', {
- name: 'edit-profile-dialog',
- template: '#edit-profile-dialog',
- delimiters: ['${', '}'],
- props: ['model-value', 'merchant-id', 'merchant-config', 'adminkey'],
- emits: ['update:model-value', 'profile-updated'],
- data: function () {
- return {
- saving: false,
- formData: {
- name: '',
- display_name: '',
- about: '',
- picture: '',
- banner: '',
- website: '',
- nip05: '',
- lud16: ''
- }
- }
- },
- computed: {
- show: {
- get() {
- return this.modelValue
- },
- set(value) {
- this.$emit('update:model-value', value)
- }
- }
- },
- methods: {
- saveProfile: async function () {
- this.saving = true
- try {
- const config = {
- ...this.merchantConfig,
- name: this.formData.name || null,
- display_name: this.formData.display_name || null,
- about: this.formData.about || null,
- picture: this.formData.picture || null,
- banner: this.formData.banner || null,
- website: this.formData.website || null,
- nip05: this.formData.nip05 || null,
- lud16: this.formData.lud16 || null
- }
- await LNbits.api.request(
- 'PATCH',
- `/nostrmarket/api/v1/merchant/${this.merchantId}`,
- this.adminkey,
- config
- )
- // Publish to Nostr
- await LNbits.api.request(
- 'PUT',
- `/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
- this.adminkey
- )
- this.show = false
- this.$q.notify({
- type: 'positive',
- message: 'Profile saved and published to Nostr!'
- })
- this.$emit('profile-updated')
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- } finally {
- this.saving = false
- }
- },
- loadFormData: function () {
- if (this.merchantConfig) {
- this.formData.name = this.merchantConfig.name || ''
- this.formData.display_name = this.merchantConfig.display_name || ''
- this.formData.about = this.merchantConfig.about || ''
- this.formData.picture = this.merchantConfig.picture || ''
- this.formData.banner = this.merchantConfig.banner || ''
- this.formData.website = this.merchantConfig.website || ''
- this.formData.nip05 = this.merchantConfig.nip05 || ''
- this.formData.lud16 = this.merchantConfig.lud16 || ''
- }
- }
- },
- watch: {
- modelValue(newVal) {
- if (newVal) {
- this.loadFormData()
- }
- }
- }
-})
diff --git a/static/components/key-pair.js b/static/components/key-pair.js
new file mode 100644
index 0000000..5bf9d23
--- /dev/null
+++ b/static/components/key-pair.js
@@ -0,0 +1,22 @@
+window.app.component('key-pair', {
+ name: 'key-pair',
+ template: '#key-pair',
+ delimiters: ['${', '}'],
+ props: ['public-key', 'private-key'],
+ data: function () {
+ return {
+ showPrivateKey: false
+ }
+ },
+ methods: {
+ copyText: function (text, message, position) {
+ var notify = this.$q.notify
+ Quasar.copyToClipboard(text).then(function () {
+ notify({
+ message: message || 'Copied to clipboard!',
+ position: position || 'bottom'
+ })
+ })
+ }
+ }
+})
diff --git a/static/components/merchant-tab.js b/static/components/merchant-tab.js
deleted file mode 100644
index d993bd4..0000000
--- a/static/components/merchant-tab.js
+++ /dev/null
@@ -1,102 +0,0 @@
-window.app.component('merchant-tab', {
- name: 'merchant-tab',
- template: '#merchant-tab',
- delimiters: ['${', '}'],
- props: [
- 'merchant-id',
- 'inkey',
- 'adminkey',
- 'show-keys',
- 'merchant-active',
- 'public-key',
- 'private-key',
- 'is-admin',
- 'merchant-config'
- ],
- emits: [
- 'toggle-show-keys',
- 'hide-keys',
- 'merchant-deleted',
- 'toggle-merchant-state',
- 'restart-nostr-connection',
- 'profile-updated'
- ],
- data: function () {
- return {
- showEditProfileDialog: false,
- showKeysDialog: false
- }
- },
- computed: {
- marketClientUrl: function () {
- 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: {
- publishProfile: async function () {
- try {
- await LNbits.api.request(
- 'PUT',
- `/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
- this.adminkey
- )
- this.$q.notify({
- type: 'positive',
- message: 'Profile published to Nostr!'
- })
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- toggleShowKeys: function () {
- this.$emit('toggle-show-keys')
- },
- hideKeys: function () {
- this.$emit('hide-keys')
- },
- handleMerchantDeleted: function () {
- this.$emit('merchant-deleted')
- },
- removeMerchant: function () {
- const name =
- this.merchantConfig?.display_name ||
- this.merchantConfig?.name ||
- 'this merchant'
- LNbits.utils
- .confirmDialog(
- `Are you sure you want to remove "${name}"? This will delete all associated data (stalls, products, orders, messages).`
- )
- .onOk(async () => {
- try {
- await LNbits.api.request(
- 'DELETE',
- `/nostrmarket/api/v1/merchant/${this.merchantId}`,
- this.adminkey
- )
- this.$emit('merchant-deleted')
- this.$q.notify({
- type: 'positive',
- message: 'Merchant removed'
- })
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- })
- },
- toggleMerchantState: function () {
- this.$emit('toggle-merchant-state')
- },
- restartNostrConnection: function () {
- this.$emit('restart-nostr-connection')
- },
- handleImageError: function (e) {
- e.target.style.display = 'none'
- }
- }
-})
diff --git a/static/components/nostr-keys-dialog.js b/static/components/nostr-keys-dialog.js
deleted file mode 100644
index 81c451f..0000000
--- a/static/components/nostr-keys-dialog.js
+++ /dev/null
@@ -1,56 +0,0 @@
-window.app.component('nostr-keys-dialog', {
- name: 'nostr-keys-dialog',
- template: '#nostr-keys-dialog',
- delimiters: ['${', '}'],
- props: ['public-key', 'private-key', 'model-value'],
- emits: ['update:model-value'],
- data: function () {
- return {
- showNsec: false
- }
- },
- computed: {
- show: {
- get() {
- return this.modelValue
- },
- set(value) {
- this.$emit('update:model-value', value)
- }
- },
- npub: function () {
- if (!this.publicKey) return ''
- try {
- return window.NostrTools.nip19.npubEncode(this.publicKey)
- } catch (e) {
- return this.publicKey
- }
- },
- nsec: function () {
- if (!this.privateKey) return ''
- try {
- return window.NostrTools.nip19.nsecEncode(this.privateKey)
- } catch (e) {
- return this.privateKey
- }
- }
- },
- methods: {
- copyText: function (text, message) {
- var notify = this.$q.notify
- Quasar.copyToClipboard(text).then(function () {
- notify({
- message: message || 'Copied to clipboard!',
- position: 'bottom'
- })
- })
- }
- },
- watch: {
- modelValue(newVal) {
- if (!newVal) {
- this.showNsec = false
- }
- }
- }
-})
diff --git a/static/components/product-list.js b/static/components/product-list.js
deleted file mode 100644
index 78379ae..0000000
--- a/static/components/product-list.js
+++ /dev/null
@@ -1,261 +0,0 @@
-window.app.component('product-list', {
- name: 'product-list',
- template: '#product-list',
- delimiters: ['${', '}'],
- props: ['adminkey', 'inkey', 'stall-filter'],
- data: function () {
- return {
- filter: '',
- stalls: [],
- products: [],
- pendingProducts: [],
- selectedStall: null,
- productDialog: {
- showDialog: false,
- showRestore: false,
- data: null
- },
- productsTable: {
- columns: [
- {name: 'name', align: 'left', label: 'Name', field: 'name'},
- {name: 'stall', align: 'left', label: 'Stall', field: 'stall_id'},
- {name: 'price', align: 'left', label: 'Price', field: 'price'},
- {
- name: 'quantity',
- align: 'left',
- label: 'Quantity',
- field: 'quantity'
- },
- {name: 'actions', align: 'right', label: 'Actions', field: ''}
- ],
- pagination: {
- rowsPerPage: 10
- }
- }
- }
- },
- computed: {
- stallOptions: function () {
- return this.stalls.map(s => ({
- label: s.name,
- value: s.id
- }))
- },
- filteredProducts: function () {
- if (!this.selectedStall) {
- return this.products
- }
- return this.products.filter(p => p.stall_id === this.selectedStall)
- }
- },
- watch: {
- stallFilter: {
- immediate: true,
- handler(newVal) {
- if (newVal) {
- this.selectedStall = newVal
- }
- }
- }
- },
- methods: {
- getStalls: async function () {
- try {
- const {data} = await LNbits.api.request(
- 'GET',
- '/nostrmarket/api/v1/stall?pending=false',
- this.inkey
- )
- this.stalls = data
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- getProducts: async function () {
- try {
- // Fetch products from all stalls
- const allProducts = []
- for (const stall of this.stalls) {
- const {data} = await LNbits.api.request(
- 'GET',
- `/nostrmarket/api/v1/stall/product/${stall.id}?pending=false`,
- this.inkey
- )
- allProducts.push(...data)
- }
- this.products = allProducts
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- getPendingProducts: async function () {
- try {
- // Fetch pending products from all stalls
- const allPending = []
- for (const stall of this.stalls) {
- const {data} = await LNbits.api.request(
- 'GET',
- `/nostrmarket/api/v1/stall/product/${stall.id}?pending=true`,
- this.inkey
- )
- allPending.push(...data)
- }
- this.pendingProducts = allPending
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- getStallName: function (stallId) {
- const stall = this.stalls.find(s => s.id === stallId)
- return stall ? stall.name : 'Unknown'
- },
- getStallCurrency: function (stallId) {
- const stall = this.stalls.find(s => s.id === stallId)
- return stall ? stall.currency : 'sat'
- },
- getStall: function (stallId) {
- return this.stalls.find(s => s.id === stallId)
- },
- newEmptyProductData: function () {
- return {
- id: null,
- stall_id: this.stalls.length ? this.stalls[0].id : null,
- name: '',
- categories: [],
- images: [],
- image: null,
- price: 0,
- quantity: 0,
- config: {
- description: '',
- use_autoreply: false,
- autoreply_message: ''
- }
- }
- },
- showNewProductDialog: function () {
- this.productDialog.data = this.newEmptyProductData()
- this.productDialog.showDialog = true
- },
- editProduct: function (product) {
- this.productDialog.data = {...product, image: null}
- if (!this.productDialog.data.config) {
- this.productDialog.data.config = {description: ''}
- }
- this.productDialog.showDialog = true
- },
- sendProductFormData: async function () {
- const data = {
- stall_id: this.productDialog.data.stall_id,
- id: this.productDialog.data.id,
- name: this.productDialog.data.name,
- images: this.productDialog.data.images || [],
- price: this.productDialog.data.price,
- quantity: this.productDialog.data.quantity,
- categories: this.productDialog.data.categories || [],
- config: this.productDialog.data.config
- }
- this.productDialog.showDialog = false
-
- if (this.productDialog.data.id) {
- data.pending = false
- await this.updateProduct(data)
- } else {
- await this.createProduct(data)
- }
- },
- createProduct: async function (payload) {
- try {
- const {data} = await LNbits.api.request(
- 'POST',
- '/nostrmarket/api/v1/product',
- this.adminkey,
- payload
- )
- this.products.unshift(data)
- this.$q.notify({
- type: 'positive',
- message: 'Product Created'
- })
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- updateProduct: async function (product) {
- try {
- const {data} = await LNbits.api.request(
- 'PATCH',
- '/nostrmarket/api/v1/product/' + product.id,
- this.adminkey,
- product
- )
- const index = this.products.findIndex(p => p.id === product.id)
- if (index !== -1) {
- this.products.splice(index, 1, data)
- } else {
- this.products.unshift(data)
- }
- this.$q.notify({
- type: 'positive',
- message: 'Product Updated'
- })
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- deleteProduct: function (product) {
- LNbits.utils
- .confirmDialog(`Are you sure you want to delete "${product.name}"?`)
- .onOk(async () => {
- try {
- await LNbits.api.request(
- 'DELETE',
- '/nostrmarket/api/v1/product/' + product.id,
- this.adminkey
- )
- this.products = this.products.filter(p => p.id !== product.id)
- this.$q.notify({
- type: 'positive',
- message: 'Product Deleted'
- })
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- })
- },
- toggleProductActive: async function (product) {
- await this.updateProduct({...product, active: !product.active})
- },
- addProductImage: function () {
- if (!this.productDialog.data.image) return
- if (!this.productDialog.data.images) {
- this.productDialog.data.images = []
- }
- this.productDialog.data.images.push(this.productDialog.data.image)
- this.productDialog.data.image = null
- },
- removeProductImage: function (imageUrl) {
- const index = this.productDialog.data.images.indexOf(imageUrl)
- if (index !== -1) {
- this.productDialog.data.images.splice(index, 1)
- }
- },
- openSelectPendingProductDialog: async function () {
- await this.getPendingProducts()
- this.productDialog.showRestore = true
- },
- openRestoreProductDialog: function (pendingProduct) {
- pendingProduct.pending = true
- this.productDialog.data = {...pendingProduct, image: null}
- this.productDialog.showDialog = true
- },
- shortLabel: function (value = '') {
- if (value.length <= 44) return value
- return value.substring(0, 40) + '...'
- }
- },
- created: async function () {
- await this.getStalls()
- await this.getProducts()
- }
-})
diff --git a/static/components/shipping-zones-list.js b/static/components/shipping-zones-list.js
deleted file mode 100644
index d93b26c..0000000
--- a/static/components/shipping-zones-list.js
+++ /dev/null
@@ -1,209 +0,0 @@
-window.app.component('shipping-zones-list', {
- name: 'shipping-zones-list',
- props: ['adminkey', 'inkey'],
- template: '#shipping-zones-list',
- delimiters: ['${', '}'],
- data: function () {
- return {
- zones: [],
- filter: '',
- zoneDialog: {
- showDialog: false,
- data: {
- id: null,
- name: '',
- countries: [],
- cost: 0,
- currency: 'sat'
- }
- },
- currencies: [],
- shippingZoneOptions: [
- 'Free (digital)',
- 'Worldwide',
- 'Europe',
- 'Australia',
- 'Austria',
- 'Belgium',
- 'Brazil',
- 'Canada',
- 'China',
- 'Denmark',
- 'Finland',
- 'France',
- 'Germany',
- 'Greece',
- 'Hong Kong',
- 'Hungary',
- 'Indonesia',
- 'Ireland',
- 'Israel',
- 'Italy',
- 'Japan',
- 'Kazakhstan',
- 'Korea',
- 'Luxembourg',
- 'Malaysia',
- 'Mexico',
- 'Netherlands',
- 'New Zealand',
- 'Norway',
- 'Poland',
- 'Portugal',
- 'Romania',
- 'Russia',
- 'Saudi Arabia',
- 'Singapore',
- 'Spain',
- 'Sweden',
- 'Switzerland',
- 'Thailand',
- 'Turkey',
- 'Ukraine',
- 'United Kingdom',
- 'United States',
- 'Vietnam'
- ],
- zonesTable: {
- columns: [
- {
- name: 'name',
- align: 'left',
- label: 'Name',
- field: 'name',
- sortable: true
- },
- {
- name: 'countries',
- align: 'left',
- label: 'Countries',
- field: 'countries',
- sortable: true
- },
- {
- name: 'cost',
- align: 'left',
- label: 'Cost',
- field: 'cost',
- sortable: true
- },
- {
- name: 'currency',
- align: 'left',
- label: 'Currency',
- field: 'currency',
- sortable: true
- },
- {
- name: 'actions',
- align: 'right',
- label: 'Actions',
- field: ''
- }
- ],
- pagination: {
- rowsPerPage: 10,
- sortBy: 'name',
- descending: false
- }
- }
- }
- },
- methods: {
- openZoneDialog: function (data) {
- data = data || {
- id: null,
- name: '',
- countries: [],
- cost: 0,
- currency: 'sat'
- }
- this.zoneDialog.data = {...data}
- this.zoneDialog.showDialog = true
- },
- getZones: async function () {
- try {
- const {data} = await LNbits.api.request(
- 'GET',
- '/nostrmarket/api/v1/zone',
- this.inkey
- )
- this.zones = data
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- sendZoneFormData: async function () {
- this.zoneDialog.showDialog = false
- if (this.zoneDialog.data.id) {
- await this.updateShippingZone(this.zoneDialog.data)
- } else {
- await this.createShippingZone(this.zoneDialog.data)
- }
- await this.getZones()
- },
- createShippingZone: async function (newZone) {
- try {
- await LNbits.api.request(
- 'POST',
- '/nostrmarket/api/v1/zone',
- this.adminkey,
- newZone
- )
- this.$q.notify({
- type: 'positive',
- message: 'Zone created!'
- })
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- updateShippingZone: async function (updatedZone) {
- try {
- await LNbits.api.request(
- 'PATCH',
- `/nostrmarket/api/v1/zone/${updatedZone.id}`,
- this.adminkey,
- updatedZone
- )
- this.$q.notify({
- type: 'positive',
- message: 'Zone updated!'
- })
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- confirmDeleteZone: function (zone) {
- LNbits.utils
- .confirmDialog(`Are you sure you want to delete zone "${zone.name}"?`)
- .onOk(async () => {
- await this.deleteShippingZone(zone.id)
- })
- },
- deleteShippingZone: async function (zoneId) {
- try {
- await LNbits.api.request(
- 'DELETE',
- `/nostrmarket/api/v1/zone/${zoneId}`,
- this.adminkey
- )
- this.$q.notify({
- type: 'positive',
- message: 'Zone deleted!'
- })
- await this.getZones()
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- getCurrencies() {
- const currencies = window.g.allowedCurrencies || []
- this.currencies = ['sat', ...currencies]
- }
- },
- created: async function () {
- await this.getZones()
- this.getCurrencies()
- }
-})
diff --git a/static/components/shipping-zones.js b/static/components/shipping-zones.js
index 9332d1e..742021a 100644
--- a/static/components/shipping-zones.js
+++ b/static/components/shipping-zones.js
@@ -19,6 +19,7 @@ window.app.component('shipping-zones', {
currencies: [],
shippingZoneOptions: [
'Free (digital)',
+ 'Flat rate',
'Worldwide',
'Europe',
'Australia',
@@ -26,7 +27,6 @@ window.app.component('shipping-zones', {
'Belgium',
'Brazil',
'Canada',
- 'China',
'Denmark',
'Finland',
'France',
@@ -34,8 +34,8 @@ window.app.component('shipping-zones', {
'Greece',
'Hong Kong',
'Hungary',
- 'Indonesia',
'Ireland',
+ 'Indonesia',
'Israel',
'Italy',
'Japan',
@@ -59,9 +59,10 @@ window.app.component('shipping-zones', {
'Thailand',
'Turkey',
'Ukraine',
- 'United Kingdom',
- 'United States',
- 'Vietnam'
+ 'United Kingdom**',
+ 'United States***',
+ 'Vietnam',
+ 'China'
]
}
},
@@ -161,13 +162,22 @@ window.app.component('shipping-zones', {
LNbits.utils.notifyApiError(error)
}
},
- getCurrencies() {
- const currencies = window.g.allowedCurrencies || []
- this.currencies = ['sat', ...currencies]
+ async getCurrencies() {
+ try {
+ const {data} = await LNbits.api.request(
+ 'GET',
+ '/nostrmarket/api/v1/currencies',
+ this.inkey
+ )
+
+ this.currencies = ['sat', ...data]
+ } catch (error) {
+ LNbits.utils.notifyApiError(error)
+ }
}
},
created: async function () {
await this.getZones()
- this.getCurrencies()
+ await this.getCurrencies()
}
})
diff --git a/static/components/stall-list.js b/static/components/stall-list.js
index 220b5c1..1ef4d70 100644
--- a/static/components/stall-list.js
+++ b/static/components/stall-list.js
@@ -2,7 +2,7 @@ window.app.component('stall-list', {
name: 'stall-list',
template: '#stall-list',
delimiters: ['${', '}'],
- props: ['adminkey', 'inkey', 'wallet-options'],
+ props: [`adminkey`, 'inkey', 'wallet-options'],
data: function () {
return {
filter: '',
@@ -20,21 +20,21 @@ window.app.component('stall-list', {
shippingZones: []
}
},
- editDialog: {
- show: false,
- data: {
- id: '',
- name: '',
- description: '',
- wallet: null,
- currency: 'sat',
- shippingZones: []
- }
- },
zoneOptions: [],
stallsTable: {
columns: [
- {name: 'name', align: 'left', label: 'Name', field: 'name'},
+ {
+ name: '',
+ align: 'left',
+ label: '',
+ field: ''
+ },
+ {
+ name: 'id',
+ align: 'left',
+ label: 'Name',
+ field: 'id'
+ },
{
name: 'currency',
align: 'left',
@@ -45,15 +45,14 @@ window.app.component('stall-list', {
name: 'description',
align: 'left',
label: 'Description',
- field: row => row.config?.description || ''
+ field: 'description'
},
{
name: 'shippingZones',
align: 'left',
label: 'Shipping Zones',
- field: row => row.shipping_zones?.map(z => z.name).join(', ') || ''
- },
- {name: 'actions', align: 'right', label: 'Actions', field: ''}
+ field: 'shippingZones'
+ }
],
pagination: {
rowsPerPage: 10
@@ -66,17 +65,9 @@ window.app.component('stall-list', {
return this.zoneOptions.filter(
z => z.currency === this.stallDialog.data.currency
)
- },
- editFilteredZoneOptions: function () {
- return this.zoneOptions.filter(
- z => z.currency === this.editDialog.data.currency
- )
}
},
methods: {
- emitStallCount: function () {
- this.$emit('stalls-updated', this.stalls.length)
- },
sendStallFormData: async function () {
const stallData = {
name: this.stallDialog.data.name,
@@ -103,8 +94,8 @@ window.app.component('stall-list', {
stall
)
this.stallDialog.show = false
+ data.expanded = false
this.stalls.unshift(data)
- this.emitStallCount()
this.$q.notify({
type: 'positive',
message: 'Stall created!'
@@ -123,8 +114,8 @@ window.app.component('stall-list', {
stallData
)
this.stallDialog.show = false
+ data.expanded = false
this.stalls.unshift(data)
- this.emitStallCount()
this.$q.notify({
type: 'positive',
message: 'Stall restored!'
@@ -133,68 +124,44 @@ window.app.component('stall-list', {
LNbits.utils.notifyApiError(error)
}
},
- updateStall: async function () {
- try {
- const stallData = {
- id: this.editDialog.data.id,
- name: this.editDialog.data.name,
- wallet: this.editDialog.data.wallet,
- currency: this.editDialog.data.currency,
- shipping_zones: this.editDialog.data.shippingZones,
- config: {
- description: this.editDialog.data.description
- }
- }
- const {data} = await LNbits.api.request(
- 'PUT',
- `/nostrmarket/api/v1/stall/${stallData.id}`,
- this.adminkey,
- stallData
- )
- this.editDialog.show = false
- const index = this.stalls.findIndex(s => s.id === data.id)
- if (index !== -1) {
- this.stalls.splice(index, 1, data)
- }
- this.emitStallCount()
- this.$q.notify({
- type: 'positive',
- message: 'Stall updated!'
- })
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- deleteStall: async function (stall) {
- try {
- await LNbits.api.request(
- 'DELETE',
- '/nostrmarket/api/v1/stall/' + stall.id,
- this.adminkey
- )
- this.stalls = this.stalls.filter(s => s.id !== stall.id)
- this.pendingStalls = this.pendingStalls.filter(s => s.id !== stall.id)
- this.emitStallCount()
- this.$q.notify({
- type: 'positive',
- message: 'Stall deleted'
- })
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- confirmDeleteStall: function (stall) {
+ deleteStall: async function (pendingStall) {
LNbits.utils
.confirmDialog(
- `Products and orders will be deleted also! Are you sure you want to delete stall "${stall.name}"?`
+ `
+ Are you sure you want to delete this pending stall '${pendingStall.name}'?
+ `
)
.onOk(async () => {
- await this.deleteStall(stall)
+ try {
+ await LNbits.api.request(
+ 'DELETE',
+ '/nostrmarket/api/v1/stall/' + pendingStall.id,
+ this.adminkey
+ )
+ this.$q.notify({
+ type: 'positive',
+ message: 'Pending Stall Deleted',
+ timeout: 5000
+ })
+ } catch (error) {
+ console.warn(error)
+ LNbits.utils.notifyApiError(error)
+ }
})
},
- getCurrencies: function () {
- const currencies = window.g.allowedCurrencies || []
- return ['sat', ...currencies]
+ getCurrencies: async function () {
+ try {
+ const {data} = await LNbits.api.request(
+ 'GET',
+ '/nostrmarket/api/v1/currencies',
+ this.inkey
+ )
+
+ return ['sat', ...data]
+ } catch (error) {
+ LNbits.utils.notifyApiError(error)
+ }
+ return []
},
getStalls: async function (pending = false) {
try {
@@ -203,7 +170,7 @@ window.app.component('stall-list', {
`/nostrmarket/api/v1/stall?pending=${pending}`,
this.inkey
)
- return data
+ return data.map(s => ({...s, expanded: false}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
@@ -227,8 +194,20 @@ window.app.component('stall-list', {
}
return []
},
+ handleStallDeleted: function (stallId) {
+ this.stalls = _.reject(this.stalls, function (obj) {
+ return obj.id === stallId
+ })
+ },
+ handleStallUpdated: function (stall) {
+ const index = this.stalls.findIndex(r => r.id === stall.id)
+ if (index !== -1) {
+ stall.expanded = true
+ this.stalls.splice(index, 1, stall)
+ }
+ },
openCreateStallDialog: async function (stallData) {
- this.currencies = this.getCurrencies()
+ this.currencies = await this.getCurrencies()
this.zoneOptions = await this.getZones()
if (!this.zoneOptions || !this.zoneOptions.length) {
this.$q.notify({
@@ -246,24 +225,6 @@ window.app.component('stall-list', {
}
this.stallDialog.show = true
},
- openEditStallDialog: async function (stall) {
- this.currencies = this.getCurrencies()
- this.zoneOptions = await this.getZones()
- this.editDialog.data = {
- id: stall.id,
- name: stall.name,
- description: stall.config?.description || '',
- wallet: stall.wallet,
- currency: stall.currency,
- shippingZones: (stall.shipping_zones || []).map(z => ({
- ...z,
- label: z.name
- ? `${z.name} (${z.countries.join(', ')})`
- : z.countries.join(', ')
- }))
- }
- this.editDialog.show = true
- },
openSelectPendingStallDialog: async function () {
this.stallDialog.showRestore = true
this.pendingStalls = await this.getStalls(true)
@@ -285,11 +246,8 @@ window.app.component('stall-list', {
}))
})
},
- goToProducts: function (stall) {
- this.$emit('go-to-products', stall.id)
- },
- goToOrders: function (stall) {
- this.$emit('go-to-orders', stall.id)
+ customerSelectedForOrder: function (customerPubkey) {
+ this.$emit('customer-selected-for-order', customerPubkey)
},
shortLabel(value = '') {
if (value.length <= 64) return value
@@ -298,8 +256,7 @@ window.app.component('stall-list', {
},
created: async function () {
this.stalls = await this.getStalls()
- this.emitStallCount()
- this.currencies = this.getCurrencies()
+ this.currencies = await this.getCurrencies()
this.zoneOptions = await this.getZones()
}
})
diff --git a/static/images/generate_logo.py b/static/images/generate_logo.py
deleted file mode 100644
index cb66c59..0000000
--- a/static/images/generate_logo.py
+++ /dev/null
@@ -1,123 +0,0 @@
-#!/usr/bin/env python3
-"""
-Generate the Nostr Market logo.
-Requires: pip install Pillow
-"""
-
-from PIL import Image, ImageDraw # type: ignore[import-not-found]
-
-# Render at 4x size for antialiasing
-scale = 4
-size = 128 * scale
-final_size = 128
-
-# Consistent color scheme with Nostr Proxy
-dark_purple = (80, 40, 120)
-light_purple = (140, 100, 180)
-white = (255, 255, 255)
-
-margin = 4 * scale
-
-swoosh_center = ((128 + 100) * scale, -90 * scale)
-swoosh_radius = 220 * scale
-
-# Create rounded rectangle mask
-mask = Image.new("L", (size, size), 0)
-mask_draw = ImageDraw.Draw(mask)
-corner_radius = 20 * scale
-mask_draw.rounded_rectangle(
- [margin, margin, size - margin, size - margin],
- radius=corner_radius,
- fill=255,
-)
-
-# Create background with swoosh
-bg = Image.new("RGBA", (size, size), (0, 0, 0, 0))
-bg_draw = ImageDraw.Draw(bg)
-bg_draw.rounded_rectangle(
- [margin, margin, size - margin, size - margin],
- radius=corner_radius,
- fill=dark_purple,
-)
-bg_draw.ellipse(
- [
- swoosh_center[0] - swoosh_radius,
- swoosh_center[1] - swoosh_radius,
- swoosh_center[0] + swoosh_radius,
- swoosh_center[1] + swoosh_radius,
- ],
- fill=light_purple,
-)
-
-# Apply rounded rectangle mask
-final = Image.new("RGBA", (size, size), (0, 0, 0, 0))
-final.paste(bg, mask=mask)
-draw = ImageDraw.Draw(final)
-
-center_x, center_y = size // 2, size // 2
-
-# Shop/storefront - wider and shorter for shop look
-shop_width = 80 * scale
-awning_height = 18 * scale
-body_height = 45 * scale
-total_height = awning_height + body_height
-
-shop_left = center_x - shop_width // 2
-shop_right = center_x + shop_width // 2
-
-# Center vertically
-awning_top = center_y - total_height // 2
-awning_bottom = awning_top + awning_height
-shop_bottom = awning_bottom + body_height
-awning_extend = 5 * scale
-
-# Draw awning background (white base)
-draw.rectangle(
- [shop_left - awning_extend, awning_top, shop_right + awning_extend, awning_bottom],
- fill=white,
-)
-
-# Vertical stripes on awning (alternating dark purple)
-stripe_count = 8
-stripe_width = (shop_width + 2 * awning_extend) // stripe_count
-for i in range(1, stripe_count, 2):
- x_left = shop_left - awning_extend + i * stripe_width
- draw.rectangle(
- [x_left, awning_top, x_left + stripe_width, awning_bottom],
- fill=dark_purple,
- )
-
-# Shop body (below awning)
-draw.rectangle(
- [shop_left, awning_bottom, shop_right, shop_bottom],
- fill=white,
-)
-
-# Large display window (shop style)
-window_margin = 8 * scale
-window_top = awning_bottom + 6 * scale
-window_bottom = shop_bottom - 6 * scale
-# Left display window
-draw.rectangle(
- [shop_left + window_margin, window_top, center_x - 10 * scale, window_bottom],
- fill=dark_purple,
-)
-# Right display window
-draw.rectangle(
- [center_x + 10 * scale, window_top, shop_right - window_margin, window_bottom],
- fill=dark_purple,
-)
-
-# Door (center, dark purple cutout)
-door_width = 14 * scale
-door_left = center_x - door_width // 2
-draw.rectangle(
- [door_left, window_top, door_left + door_width, shop_bottom],
- fill=dark_purple,
-)
-
-# Downscale with LANCZOS for antialiasing
-final = final.resize((final_size, final_size), Image.LANCZOS)
-
-final.save("nostr-market.png")
-print("Logo saved to nostr-market.png")
diff --git a/static/images/nostr-market.png b/static/images/nostr-market.png
deleted file mode 100644
index 3e924b5..0000000
Binary files a/static/images/nostr-market.png and /dev/null differ
diff --git a/static/js/index.js b/static/js/index.js
index f5d2e62..3d89779 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -5,60 +5,45 @@ window.app = Vue.createApp({
mixins: [window.windowMixin],
data: function () {
return {
- activeTab: 'orders',
- selectedStallFilter: null,
merchant: {},
shippingZones: [],
activeChatCustomer: '',
orderPubkey: null,
showKeys: false,
- stallCount: 0,
- wsConnection: null,
- nostrStatus: {
- connected: false,
- error: null,
- relays_connected: 0,
- relays_total: 0
- }
- }
- },
- computed: {
- nostrStatusColor: function () {
- if (this.nostrStatus.connected) {
- return 'green'
- } else if (this.nostrStatus.warning) {
- return 'orange'
- }
- return 'red'
- },
- nostrStatusLabel: function () {
- return 'Connect'
+ importKeyDialog: {
+ show: false,
+ data: {
+ privateKey: null
+ }
+ },
+ wsConnection: null
}
},
methods: {
- 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)
- }
+ generateKeys: async function () {
+ const privateKey = nostr.generatePrivateKey()
+ await this.createMerchant(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
+ }
+ } catch (error) {
+ this.$q.notify({
+ type: 'negative',
+ message: `${error}`
})
+ }
+ await this.createMerchant(privateKey)
+ },
+ showImportKeysDialog: async function () {
+ this.importKeyDialog.show = true
},
toggleShowKeys: function () {
this.showKeys = !this.showKeys
@@ -108,11 +93,13 @@ window.app = Vue.createApp({
this.shippingZones = []
this.activeChatCustomer = ''
this.showKeys = false
- this.stallCount = 0
},
- createMerchant: async function () {
+ createMerchant: async function (privateKey) {
try {
+ const pubkey = nostr.getPublicKey(privateKey)
const payload = {
+ private_key: privateKey,
+ public_key: pubkey,
config: {}
}
const {data} = await LNbits.api.request(
@@ -209,132 +196,10 @@ window.app = Vue.createApp({
})
}
},
- checkNostrStatus: async function (showNotification = false) {
- try {
- const response = await fetch('/nostrclient/api/v1/relays')
- const body = await response.json()
-
- if (response.status === 200) {
- const relaysConnected = body.filter(r => r.connected).length
- if (body.length === 0) {
- this.nostrStatus = {
- connected: false,
- error: 'No relays configured in Nostr Client',
- relays_connected: 0,
- relays_total: 0,
- warning: true
- }
- } else {
- this.nostrStatus = {
- connected: true,
- error: null,
- relays_connected: relaysConnected,
- relays_total: body.length
- }
- }
- } else {
- this.nostrStatus = {
- connected: false,
- error: body.detail,
- relays_connected: 0,
- relays_total: 0
- }
- }
-
- if (showNotification) {
- this.$q.notify({
- timeout: 3000,
- type: this.nostrStatus.connected ? 'positive' : 'warning',
- message: this.nostrStatus.connected ? 'Connected' : 'Disconnected',
- caption: this.nostrStatus.error || undefined
- })
- }
- } catch (error) {
- console.error('Failed to check nostr status:', error)
- this.nostrStatus = {
- connected: false,
- error: error.message,
- relays_connected: 0,
- relays_total: 0
- }
- if (showNotification) {
- this.$q.notify({
- timeout: 5000,
- type: 'negative',
- message: this.nostrStatus.error
- })
- }
- }
- },
restartNostrConnection: async function () {
LNbits.utils
.confirmDialog(
- 'Are you sure you want to reconnect to the nostrclient extension?'
- )
- .onOk(async () => {
- try {
- this.$q.notify({
- timeout: 2000,
- type: 'info',
- message: 'Reconnecting...'
- })
- await LNbits.api.request(
- 'PUT',
- '/nostrmarket/api/v1/restart',
- this.g.user.wallets[0].adminkey
- )
- // Check status after restart (give time for websocket to reconnect)
- setTimeout(() => this.checkNostrStatus(true), 3000)
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- })
- },
- publishNip15: async function () {
- try {
- const {data: stalls} = await LNbits.api.request(
- 'GET',
- '/nostrmarket/api/v1/stall?pending=false',
- this.g.user.wallets[0].inkey
- )
- for (const stall of stalls) {
- await LNbits.api.request(
- 'PUT',
- `/nostrmarket/api/v1/stall/${stall.id}`,
- this.g.user.wallets[0].adminkey,
- stall
- )
- }
- // Fetch products from all stalls
- let productCount = 0
- for (const stall of stalls) {
- const {data: products} = await LNbits.api.request(
- 'GET',
- `/nostrmarket/api/v1/stall/product/${stall.id}?pending=false`,
- this.g.user.wallets[0].inkey
- )
- for (const product of products) {
- await LNbits.api.request(
- 'PATCH',
- `/nostrmarket/api/v1/product/${product.id}`,
- this.g.user.wallets[0].adminkey,
- product
- )
- productCount++
- }
- }
- this.$q.notify({
- type: 'positive',
- message: `Published ${stalls.length} stall(s) and ${productCount} product(s) to Nostr (NIP-15)`
- })
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- refreshNip15: async function () {
- LNbits.utils
- .confirmDialog(
- 'This will sync your stalls and products from Nostr relays. Continue?'
+ 'Are you sure you want to reconnect to the nostrcient extension?'
)
.onOk(async () => {
try {
@@ -343,42 +208,14 @@ window.app = Vue.createApp({
'/nostrmarket/api/v1/restart',
this.g.user.wallets[0].adminkey
)
- this.$q.notify({
- type: 'positive',
- message: 'Refreshing NIP-15 data from Nostr...'
- })
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
- },
- deleteNip15: async function () {
- LNbits.utils
- .confirmDialog(
- 'WARNING: This will delete all your stalls and products from Nostr relays. This cannot be undone! Are you sure?'
- )
- .onOk(async () => {
- this.$q.notify({
- type: 'info',
- message: 'Delete NIP-15 from Nostr not yet implemented'
- })
- })
- },
- goToProducts: function (stallId) {
- this.selectedStallFilter = stallId
- this.activeTab = 'products'
- },
- goToOrders: function (stallId) {
- this.selectedStallFilter = stallId
}
},
created: async function () {
- const merchant = await this.getMerchant()
- if (!merchant) {
- // Auto-create merchant using the account's existing Nostr keypair
- await this.createMerchant()
- }
- await this.checkNostrStatus()
+ await this.getMerchant()
setInterval(async () => {
if (
!this.wsConnection ||
diff --git a/static/market/js/utils.js b/static/market/js/utils.js
index 8a3a98b..2e41b49 100644
--- a/static/market/js/utils.js
+++ b/static/market/js/utils.js
@@ -1,43 +1,5 @@
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',
@@ -82,24 +44,13 @@ function confirm(message) {
async function hash(string) {
- 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')
+ 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
}
function isJson(str) {
diff --git a/tasks.py b/tasks.py
index c147936..013a281 100644
--- a/tasks.py
+++ b/tasks.py
@@ -1,5 +1,4 @@
import asyncio
-import time
from asyncio import Queue
from lnbits.core.models import Payment
@@ -10,13 +9,9 @@ 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()
@@ -40,38 +35,13 @@ 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"[NOSTRMARKET] Subscription failed. Retrying in 10s: {e}")
+ logger.warning(f"Subcription failed. Will retry in one minute: {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/_api_docs.html b/templates/nostrmarket/_api_docs.html
index 664d6ba..6bce480 100644
--- a/templates/nostrmarket/_api_docs.html
+++ b/templates/nostrmarket/_api_docs.html
@@ -1,213 +1,44 @@
-
-
-
-
- Nostr (Notes and Other Stuff Transmitted by Relays) is
- a decentralized protocol for censorship-resistant communication. Unlike
- traditional platforms, your identity and data aren't controlled by any
- single company.
-
-
- Your Nostr identity is a cryptographic key pair - a public key (npub)
- that others use to find you, and a private key (nsec) that proves you
- are you. Keep your nsec safe and never share it!
-
-
-
-
-
-
-
-
-
1. Generate or Import Keys
-
- Create a new Nostr identity or import an existing one using your nsec.
- Your keys are used to sign all marketplace events.
-
-
2. Create a Stall
-
- A stall is your shop. Give it a name, description, and configure
- shipping zones for delivery.
-
-
3. Add Products
-
- List items for sale with images, descriptions, and prices in your
- preferred currency.
-
-
4. Publish to Nostr
-
- Your stall and products are published to Nostr relays where customers
- can discover them using any compatible marketplace client.
-
-
-
-
-
-
-
-
-
- Decentralized Commerce - Your shop exists on Nostr
- relays, not a single server. No platform fees, no deplatforming risk.
-
-
- Lightning Payments - Accept instant, low-fee Bitcoin
- payments via the Lightning Network.
-
-
- Encrypted Messages - Communicate privately with
- customers using NIP-04 encrypted direct messages.
-
-
- Portable Identity - Your merchant reputation travels
- with your Nostr keys across any compatible marketplace.
-
-
- Global Reach - Your stalls and products are
- automatically visible on any Nostr marketplace client that supports
- NIP-15, including Amethyst, Plebeian Market, and others.
-
-
-
-
-
-
-
-
-
- Browse the Market - Use the Market Client to discover
- stalls and products from merchants around the world.
-
-
- Pay with Lightning - Fast, private payments with
- minimal fees using Bitcoin's Lightning Network.
-
-
- Direct Communication - Message merchants directly via
- encrypted Nostr DMs for questions, custom orders, or support.
-
-
-
-
-
- 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.
-
-
-
-
-
+
+ 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
+
+
+
+
+ Restart the connection to the nostrclient extension
+
+
+
+
+
+
+
+
+
+ {{SITE_TITLE}} Nostr Market Extension
+
+
+
+
+ {% include "nostrmarket/_api_docs.html" %}
@@ -335,36 +189,34 @@
>
-
-
-
-
-
-
Nostr Market
-
- A decentralized marketplace extension for LNbits implementing the
- NIP-15 protocol. Create stalls, list products, and accept
- Lightning payments while communicating with customers via
- encrypted Nostr direct messages.
-