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 @@ - - - - LNbits - - - -[![License: MIT](https://img.shields.io/badge/License-MIT-success?logo=open-source-initiative&logoColor=white)](./LICENSE) -[![Built for LNbits](https://img.shields.io/badge/Built%20for-LNbits-4D4DFF?logo=lightning&logoColor=white)](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. - -[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) -[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](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. -

-
-
-
- - - - -

This extension was created by:

-
+ + +

+ Nostr Market
+ + Created by, Tal Vasconcelos - Tal Vasconcelos - Ben Arc - Ben Arc - - motorina0 - - - Ben Weeks - -

-
-
-
- - - - - - - - - Market Client - Browse and shop from stalls - - - - - - - - - - - - API Documentation - Swagger REST API reference - - - - - - - - - - - - NIP-15 Specification - Nostr Marketplace protocol - - - - - - - - - - - - Report Issues / Feedback - GitHub Issues - - - - - + >motorina0 +

+ Swagger REST API Documentation + + + Visit the market clientMarket client + + diff --git a/templates/nostrmarket/components/direct-messages.html b/templates/nostrmarket/components/direct-messages.html index 602eb8b..9f68511 100644 --- a/templates/nostrmarket/components/direct-messages.html +++ b/templates/nostrmarket/components/direct-messages.html @@ -1,147 +1,143 @@
- - -
-
-   new -
-
- Client Orders -
+ +
+
+
Messages
- - -
-
- - - -
-
- - Add a public key to chat with - -
+
+   new
- - -
-
-
- -
-
- New order: -
-
- Reply sent for order: -
-
- Paid - - Shipped - -
-
- - - - -
- ... + Client Orders +
+
+ + + + + +
+
+ + +
- - +
+ + Add a public key to chat with + +
+
+
+ +
+
+
+ +
+
+ New order: +
+
+ Reply sent for order: +
+
+ Paid + Shipped + +
+
+ + + + +
+ ... +
+
+
+
+
+ + + + + + + +
+
diff --git a/templates/nostrmarket/components/edit-profile-dialog.html b/templates/nostrmarket/components/edit-profile-dialog.html deleted file mode 100644 index a447237..0000000 --- a/templates/nostrmarket/components/edit-profile-dialog.html +++ /dev/null @@ -1,68 +0,0 @@ - - - -
Edit Profile
- - - - - - - - -
- Save & Publish - Cancel -
-
-
-
diff --git a/templates/nostrmarket/components/key-pair.html b/templates/nostrmarket/components/key-pair.html new file mode 100644 index 0000000..911e057 --- /dev/null +++ b/templates/nostrmarket/components/key-pair.html @@ -0,0 +1,93 @@ +
+ + + +
+
Keys
+ +
+ + +
+ +
+ + +
Public Key
+
+ + + +
+
+ ... +
+ +
+
+
+ + +
+ + +
+ + Private Key (Keep Secret!) +
+
+ + + +
+
+ ... +
+ +
+
+
+
+
diff --git a/templates/nostrmarket/components/merchant-tab.html b/templates/nostrmarket/components/merchant-tab.html deleted file mode 100644 index 497478a..0000000 --- a/templates/nostrmarket/components/merchant-tab.html +++ /dev/null @@ -1,271 +0,0 @@ -
-
-
- - -
-
- Merchant Profile -
-
-
- - - - - - - - View Keys - Show public/private keys - - - - Saved Profiles - - - - - - - - - - - - - Remove profile - - - - - - - - - - - - - - Accepting Orders - Orders Paused - - New orders will be processed - - - New orders will be ignored - - - - - - - - - - Pause Orders - Resume Orders - - Stop accepting new orders - - - Start accepting new orders - - - - - - -
-
- - -
- - -
- - - -
- -
- - - - -
- - -
-
-
-
- -
-
- (No display name set) -
-
- -
-
-
- 0 Following - Not implemented yet -
-
- 0 Followers - Not implemented yet -
-
-
-
-
- -
-
-
- - -
-
- - -
-
-
-
-
- -
- - - New Post (Coming soon) - -
- - - -
- -
Coming Soon
-
- Merchant posts will appear here -
-
-
-
-
-
- - - - - - -
diff --git a/templates/nostrmarket/components/nostr-keys-dialog.html b/templates/nostrmarket/components/nostr-keys-dialog.html deleted file mode 100644 index dde7069..0000000 --- a/templates/nostrmarket/components/nostr-keys-dialog.html +++ /dev/null @@ -1,75 +0,0 @@ - - - -
Nostr Keys
-
- - -
- -
- - -
- - Public Key (npub) -
- - - - - -
- - Private Key (nsec) -
- - - -
- - Never share your private key! -
-
- - - -
-
diff --git a/templates/nostrmarket/components/product-list.html b/templates/nostrmarket/components/product-list.html deleted file mode 100644 index 6d24b84..0000000 --- a/templates/nostrmarket/components/product-list.html +++ /dev/null @@ -1,309 +0,0 @@ -
-
-
- - - -
-
- - - - - All Stalls - - - - - - - - - - - - - - - - -
-
- -
-
- -
-
- -
- -
No stalls found. Please create a stall first in the Stalls tab.
-
- - - - - - - - - - - - - - - -
-
- -
-
- -
-
- - -
- -
-
- - -
- - - - - - -
-
- -
- - Create Product - Cancel -
-
-
-
- - - - -
- - - - - - - Restore - - -
-
There are no products to be restored.
-
- Close -
-
-
-
diff --git a/templates/nostrmarket/components/shipping-zones-list.html b/templates/nostrmarket/components/shipping-zones-list.html deleted file mode 100644 index 111ba90..0000000 --- a/templates/nostrmarket/components/shipping-zones-list.html +++ /dev/null @@ -1,136 +0,0 @@ -
-
-
- - - -
-
- -
-
- - - - - - - - - - - - -
-
- Update -
-
- Create Shipping Zone -
- - Cancel -
-
-
-
-
diff --git a/templates/nostrmarket/components/shipping-zones.html b/templates/nostrmarket/components/shipping-zones.html index b0dbc34..3f0fd08 100644 --- a/templates/nostrmarket/components/shipping-zones.html +++ b/templates/nostrmarket/components/shipping-zones.html @@ -48,36 +48,26 @@ label="Countries" v-model="zoneDialog.data.countries" > -
-
- -
-
- -
-
+ +
Update @@ -93,7 +83,7 @@ Create Shipping Zone diff --git a/templates/nostrmarket/components/stall-list.html b/templates/nostrmarket/components/stall-list.html index a680a50..673e8a7 100644 --- a/templates/nostrmarket/components/stall-list.html +++ b/templates/nostrmarket/components/stall-list.html @@ -1,38 +1,43 @@
-
-
+
+
+ + + + New Stall + Create a new stall + + + + + Restore Stall + Restore existing stall from Nostr + + + - {% include("nostrmarket/components/merchant-details.html") %} - - - - - + - - - {% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 22ffb83..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Stub out the nostrmarket root package and all LNbits dependencies so that -nostr/* unit tests can run without the full LNbits environment. - -pytest walks up from tests/ and tries to import the parent __init__.py, -which pulls in fastapi, lnbits, websocket, etc. We preemptively register -the parent package as a simple module so that import never happens. -""" - -import sys -import types -from pathlib import Path - -# Register 'nostrmarket' as an already-imported namespace package -# pointing at the extension root, so pytest doesn't try to exec __init__.py -_ext_root = Path(__file__).resolve().parent.parent -_pkg = types.ModuleType("nostrmarket") -_pkg.__path__ = [str(_ext_root)] -_pkg.__package__ = "nostrmarket" -sys.modules["nostrmarket"] = _pkg - -# Also ensure the nostr subpackage is importable -_nostr_dir = _ext_root / "nostr" -_nostr_pkg = types.ModuleType("nostrmarket.nostr") -_nostr_pkg.__path__ = [str(_nostr_dir)] -_nostr_pkg.__package__ = "nostrmarket.nostr" -sys.modules["nostrmarket.nostr"] = _nostr_pkg diff --git a/tests/test_nip44.py b/tests/test_nip44.py deleted file mode 100644 index 3e767a6..0000000 --- a/tests/test_nip44.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Tests for NIP-44 v2 encryption against official spec test vectors.""" - -import coincurve -import pytest - -from nostr.nip44 import ( - calc_padded_len, - decrypt, - encrypt, - get_conversation_key, - get_message_keys, -) - - -def pubkey_from_secret(secret_hex: str) -> str: - """Derive x-only public key hex from secret key hex.""" - sk = coincurve.PrivateKey(bytes.fromhex(secret_hex)) - return sk.public_key.format(compressed=True)[1:].hex() - - -# --- Test vector from NIP-44 spec --- - -SPEC_VECTOR = { - "sec1": "0000000000000000000000000000000000000000000000000000000000000001", - "sec2": "0000000000000000000000000000000000000000000000000000000000000002", - "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", - "nonce": "0000000000000000000000000000000000000000000000000000000000000001", - "plaintext": "a", - "payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb", -} - - -class TestConversationKey: - def test_spec_vector(self): - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - assert key.hex() == SPEC_VECTOR["conversation_key"] - - def test_symmetric(self): - """conv(a, B) == conv(b, A)""" - pub1 = pubkey_from_secret(SPEC_VECTOR["sec1"]) - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - key_ab = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - key_ba = get_conversation_key(SPEC_VECTOR["sec2"], pub1) - assert key_ab == key_ba - - -class TestMessageKeys: - def test_returns_correct_lengths(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - nonce = bytes.fromhex(SPEC_VECTOR["nonce"]) - chacha_key, chacha_nonce, hmac_key = get_message_keys(conv_key, nonce) - assert len(chacha_key) == 32 - assert len(chacha_nonce) == 12 - assert len(hmac_key) == 32 - - def test_rejects_bad_key_length(self): - with pytest.raises(ValueError): - get_message_keys(b"\x00" * 16, b"\x00" * 32) - - def test_rejects_bad_nonce_length(self): - with pytest.raises(ValueError): - get_message_keys(b"\x00" * 32, b"\x00" * 16) - - -class TestPadding: - @pytest.mark.parametrize( - "unpadded,expected", - [ - (1, 32), - (2, 32), - (31, 32), - (32, 32), - (33, 64), - (64, 64), - (65, 96), - (256, 256), - (257, 320), - (1024, 1024), - (65535, 65536), - ], - ) - def test_calc_padded_len(self, unpadded, expected): - assert calc_padded_len(unpadded) == expected - - def test_rejects_zero(self): - with pytest.raises(ValueError): - calc_padded_len(0) - - -class TestEncryptDecrypt: - def test_spec_vector(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - nonce = bytes.fromhex(SPEC_VECTOR["nonce"]) - payload = encrypt(SPEC_VECTOR["plaintext"], conv_key, nonce) - assert payload == SPEC_VECTOR["payload"] - - def test_spec_vector_decrypt(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - plaintext = decrypt(SPEC_VECTOR["payload"], conv_key) - assert plaintext == SPEC_VECTOR["plaintext"] - - def test_round_trip_short(self): - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - msg = "x" - assert decrypt(encrypt(msg, conv_key), conv_key) == msg - - def test_round_trip_long(self): - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - msg = "A" * 65535 - assert decrypt(encrypt(msg, conv_key), conv_key) == msg - - def test_round_trip_unicode(self): - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - msg = "hello world! \U0001f680\U0001f30e\U0001f4ac" - assert decrypt(encrypt(msg, conv_key), conv_key) == msg - - def test_tampered_mac_rejected(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - payload = SPEC_VECTOR["payload"] - tampered = payload[:-1] + ("a" if payload[-1] != "a" else "b") - with pytest.raises(ValueError, match="invalid MAC"): - decrypt(tampered, conv_key) - - def test_empty_plaintext_rejected(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - with pytest.raises(ValueError, match="invalid plaintext length"): - encrypt("", conv_key) - - def test_unknown_version_rejected(self): - with pytest.raises(ValueError, match="unknown version"): - decrypt("#invalid", bytes(32)) - - def test_short_payload_rejected(self): - with pytest.raises(ValueError, match="invalid payload size"): - decrypt("AAAA", bytes(32)) diff --git a/tests/test_nip59.py b/tests/test_nip59.py deleted file mode 100644 index 5751990..0000000 --- a/tests/test_nip59.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Tests for NIP-59 gift wrap protocol. - -Post-aiolabs/nostrmarket#5: the merchant-identity crypto operations -(`create_seal`, `unseal`, `unwrap_gift_wrap`, `wrap_message`, -`unwrap_message`) are async + take a `NostrSigner`-shaped object -instead of a raw privkey. These tests use a local-privkey-backed -fake signer so the NIP-59 plumbing can be tested in isolation — -the real runtime uses `RemoteBunkerSigner` against nsecbunkerd. -""" - -import json -import time - -import coincurve -import pytest - -from nostr.event import NostrEvent -from nostr.nip44 import decrypt as _nip44_decrypt -from nostr.nip44 import encrypt as _nip44_encrypt -from nostr.nip44 import get_conversation_key -from nostr.nip59 import ( - create_gift_wrap, - create_rumor, - create_seal, - unseal, - unwrap_gift_wrap, - unwrap_message, - wrap_message, -) - - -def _generate_keypair() -> tuple[str, str]: - """Generate a (privkey_hex, pubkey_hex) pair.""" - sk = coincurve.PrivateKey() - privkey = sk.secret.hex() - pubkey = sk.public_key.format(compressed=True)[1:].hex() - return privkey, pubkey - - -class _LocalSignerStub: - """Stand-in for the lnbits `NostrSigner` ABC backed by a held privkey. - - Provides just the surface the NIP-59 functions touch: - `pubkey`, `nip44_encrypt`, `nip44_decrypt`, `sign_event`. Useful for - unit-testing the NIP-59 plumbing without involving a bunker — the - crypto is identical, only the dispatch boundary differs. - """ - - def __init__(self, privkey_hex: str): - self._privkey = privkey_hex - sk = coincurve.PrivateKey(bytes.fromhex(privkey_hex)) - self.pubkey = sk.public_key.format(compressed=True)[1:].hex() - - async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str: - return _nip44_encrypt( - plaintext, get_conversation_key(self._privkey, peer_pubkey_hex) - ) - - async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str: - return _nip44_decrypt( - ciphertext, get_conversation_key(self._privkey, peer_pubkey_hex) - ) - - async def sign_event(self, unsigned: dict) -> dict: - evt = NostrEvent( - pubkey=unsigned["pubkey"], - created_at=unsigned["created_at"], - kind=unsigned["kind"], - tags=unsigned["tags"], - content=unsigned["content"], - ) - evt.id = evt.event_id - sk = coincurve.PrivateKey(bytes.fromhex(self._privkey)) - sig = sk.sign_schnorr(bytes.fromhex(evt.id)).hex() - return {**unsigned, "id": evt.id, "sig": sig} - - -SENDER_PRIV, SENDER_PUB = _generate_keypair() -RECIPIENT_PRIV, RECIPIENT_PUB = _generate_keypair() -SENDER_SIGNER = _LocalSignerStub(SENDER_PRIV) -RECIPIENT_SIGNER = _LocalSignerStub(RECIPIENT_PRIV) - - -class TestCreateRumor: - def test_has_id_but_no_sig(self): - rumor = create_rumor(SENDER_PUB, "hello", kind=14) - assert rumor.id != "" - assert rumor.sig is None - - def test_kind_and_content(self): - rumor = create_rumor(SENDER_PUB, "test message", kind=14, tags=[["p", RECIPIENT_PUB]]) - assert rumor.kind == 14 - assert rumor.content == "test message" - assert rumor.pubkey == SENDER_PUB - assert ["p", RECIPIENT_PUB] in rumor.tags - - def test_custom_timestamp(self): - ts = 1700000000 - rumor = create_rumor(SENDER_PUB, "hello", created_at=ts) - assert rumor.created_at == ts - - -class TestCreateSeal: - @pytest.mark.asyncio - async def test_kind_13_with_empty_tags(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - assert seal.kind == 13 - assert seal.tags == [] - assert seal.pubkey == SENDER_PUB - - @pytest.mark.asyncio - async def test_is_signed(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - assert seal.sig is not None - assert len(seal.sig) == 128 # 64 bytes hex - - @pytest.mark.asyncio - async def test_content_is_encrypted(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - # Content should be base64 NIP-44 payload, not plaintext JSON - assert "hello" not in seal.content - - @pytest.mark.asyncio - async def test_timestamp_is_randomized(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - now = int(time.time()) - # Seal timestamp should be in the past (up to 2 days) - assert seal.created_at <= now - assert seal.created_at >= now - (2 * 24 * 60 * 60 + 10) - - -class TestCreateGiftWrap: - @pytest.mark.asyncio - async def test_kind_1059_with_p_tag(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - wrap = create_gift_wrap(seal, RECIPIENT_PUB) - assert wrap.kind == 1059 - assert ["p", RECIPIENT_PUB] in wrap.tags - - @pytest.mark.asyncio - async def test_uses_ephemeral_key(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - wrap = create_gift_wrap(seal, RECIPIENT_PUB) - # Gift wrap pubkey should be neither sender nor recipient - assert wrap.pubkey != SENDER_PUB - assert wrap.pubkey != RECIPIENT_PUB - - @pytest.mark.asyncio - async def test_different_wraps_have_different_ephemeral_keys(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - wrap1 = create_gift_wrap(seal, RECIPIENT_PUB) - wrap2 = create_gift_wrap(seal, RECIPIENT_PUB) - assert wrap1.pubkey != wrap2.pubkey - - -class TestUnwrap: - @pytest.mark.asyncio - async def test_unwrap_gift_wrap_returns_seal(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - wrap = create_gift_wrap(seal, RECIPIENT_PUB) - - recovered_seal = await unwrap_gift_wrap(wrap, RECIPIENT_SIGNER) - assert recovered_seal.kind == 13 - assert recovered_seal.pubkey == SENDER_PUB - - @pytest.mark.asyncio - async def test_unseal_returns_rumor(self): - rumor = create_rumor(SENDER_PUB, "hello world") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - - recovered_rumor = await unseal(seal, RECIPIENT_SIGNER) - assert recovered_rumor.content == "hello world" - assert recovered_rumor.pubkey == SENDER_PUB - assert recovered_rumor.kind == 14 - - @pytest.mark.asyncio - async def test_wrong_key_fails(self): - rumor = create_rumor(SENDER_PUB, "secret") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - wrap = create_gift_wrap(seal, RECIPIENT_PUB) - - wrong_priv, _ = _generate_keypair() - wrong_signer = _LocalSignerStub(wrong_priv) - with pytest.raises(Exception): - await unwrap_message(wrap, wrong_signer) - - -class TestFullRoundTrip: - @pytest.mark.asyncio - async def test_wrap_unwrap_message(self): - content = "Are you going to the party tonight?" - wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB) - - assert wrap.kind == 1059 - assert ["p", RECIPIENT_PUB] in wrap.tags - - rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) - assert rumor.content == content - assert rumor.pubkey == SENDER_PUB - assert rumor.kind == 14 - assert rumor.sig is None - - @pytest.mark.asyncio - async def test_wrap_with_custom_kind_and_tags(self): - tags = [["p", RECIPIENT_PUB], ["subject", "test"]] - wrap = await wrap_message( - "order data", - SENDER_SIGNER, - RECIPIENT_PUB, - kind=14, - tags=tags, - ) - - rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) - assert rumor.content == "order data" - assert rumor.kind == 14 - assert ["subject", "test"] in rumor.tags - - @pytest.mark.asyncio - async def test_self_wrap_for_archival(self): - """Merchant wraps a copy to self (same sender and recipient).""" - content = '{"type": 1, "payment_options": [{"type": "ln", "link": "lnbc..."}]}' - wrap = await wrap_message(content, SENDER_SIGNER, SENDER_PUB) - - rumor = await unwrap_message(wrap, SENDER_SIGNER) - assert rumor.content == content - assert rumor.pubkey == SENDER_PUB - - @pytest.mark.asyncio - async def test_json_content_preserved(self): - """Order JSON payloads survive the wrap/unwrap cycle.""" - order = { - "type": 0, - "id": "test-order-123", - "items": [{"product_id": "abc", "quantity": 2}], - "shipping_id": "zone-1", - } - content = json.dumps(order) - wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB) - - rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) - recovered_order = json.loads(rumor.content) - assert recovered_order == order - - @pytest.mark.asyncio - async def test_unicode_content(self): - content = "Payment received! \u2705 Your order is being processed \U0001f4e6" - wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB) - rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) - assert rumor.content == content diff --git a/views_api.py b/views_api.py index 550cca1..af675cd 100644 --- a/views_api.py +++ b/views_api.py @@ -1,13 +1,11 @@ import json from http import HTTPStatus -from typing import List, Optional from fastapi import Depends from fastapi.exceptions import HTTPException -from lnbits.core.crud import get_account +from lnbits.core.models import WalletTypeInfo from lnbits.core.services import websocket_updater from lnbits.decorators import ( - WalletTypeInfo, require_admin_key, require_invoice_key, ) @@ -38,7 +36,6 @@ from .crud import ( get_last_direct_messages_time, get_merchant_by_pubkey, get_merchant_for_user, - update_merchant_pubkey, get_order, get_order_by_event_id, get_orders, @@ -61,12 +58,10 @@ from .crud import ( ) from .helpers import normalize_public_key from .models import ( - CreateMerchantRequest, Customer, DirectMessage, DirectMessageType, Merchant, - MerchantConfig, Order, OrderReissue, OrderStatusUpdate, @@ -82,10 +77,8 @@ from .models import ( from .services import ( build_order_with_payment, create_or_update_order_from_dm, - provision_merchant, reply_to_structured_dm, resubscribe_to_all_merchants, - send_dm, sign_and_send_to_nostr, subscribe_to_all_merchants, update_merchant_to_nostr, @@ -94,55 +87,37 @@ from .services import ( ######################################## MERCHANT ###################################### -async def _auto_create_merchant( - wallet: WalletTypeInfo, - config: MerchantConfig | None = None, -) -> Merchant: - """ - Lazy fallback: provision a merchant from the user's account keypair when - the LNbits-side eager provisioning didn't run (e.g., older accounts, or - upstream LNbits without our signup hook). - - Post-aiolabs/nostrmarket#5: the merchant identity IS the lnbits - account identity. No `private_key` is read here — signing routes - through the account's `NostrSigner` (which holds a - `RemoteBunkerSigner` in our target deployment, with the nsec - living entirely in the bunker). The only precondition is that the - account already has a `pubkey` — every post-#9 account does, since - `create_account` provisions one via the bunker on signup. - """ - account = await get_account(wallet.wallet.user) - assert account, "User account not found" - assert account.pubkey, ( - "Account has no Nostr pubkey — must be migrated to RemoteBunkerSigner " - "before a merchant can be provisioned (see aiolabs/nostrmarket#5)" - ) - - merchant = await provision_merchant( - user_id=wallet.wallet.user, - wallet_id=wallet.wallet.id, - public_key=account.pubkey, - display_name=account.username, - config=config, - ) - - await resubscribe_to_all_merchants() - await nostr_client.merchant_temp_subscription(account.pubkey) - - return merchant - - @nostrmarket_ext.post("/api/v1/merchant") async def api_create_merchant( - data: CreateMerchantRequest, + data: PartialMerchant, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> Merchant: try: + merchant = await get_merchant_by_pubkey(data.public_key) + assert merchant is None, "A merchant already uses this public key" + merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant is None, "A merchant already exists for this user" - return await _auto_create_merchant(wallet, data.config) + merchant = await create_merchant(wallet.wallet.user, data) + + await create_zone( + merchant.id, + Zone( + id=f"online-{merchant.public_key}", + name="Online", + currency="sat", + cost=0, + countries=["Free (digital)"], + ), + ) + + await resubscribe_to_all_merchants() + + await nostr_client.merchant_temp_subscription(data.public_key) + + return merchant except AssertionError as ex: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -159,13 +134,12 @@ async def api_create_merchant( @nostrmarket_ext.get("/api/v1/merchant") async def api_get_merchant( wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> Merchant: +) -> Merchant | None: try: merchant = await get_merchant_for_user(wallet.wallet.user) if not merchant: - # Auto-provision merchant from the user's account keypair - merchant = await _auto_create_merchant(wallet) + return None merchant = await touch_merchant(wallet.wallet.user, merchant.id) assert merchant @@ -173,11 +147,6 @@ async def api_get_merchant( assert merchant.time merchant.config.restore_in_progress = (merchant.time - last_dm_time) < 30 - # Detect keypair rotation: account key no longer matches merchant key - account = await get_account(wallet.wallet.user) - if account and account.pubkey and account.pubkey != merchant.public_key: - merchant.config.key_mismatch = True - return merchant except Exception as ex: logger.warning(ex) @@ -223,104 +192,6 @@ async def api_delete_merchant( await subscribe_to_all_merchants() -@nostrmarket_ext.post("/api/v1/merchant/{merchant_id}/migrate-keys") -async def api_migrate_merchant_keys( - merchant_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), -) -> Merchant: - """ - Migrate a merchant to the current account keypair. - - When a user rotates their Nostr keypair, the merchant still holds the old - key. This endpoint updates the merchant's keys to match the account, - then republishes all stalls and products under the new identity. - - Orders and DM history are preserved (they reference customer pubkeys, - not the merchant key). Old stall/product events on relays become - orphaned — clients following the new pubkey will see the fresh events. - """ - try: - merchant = await get_merchant_for_user(wallet.wallet.user) - assert merchant, "Merchant cannot be found" - assert merchant.id == merchant_id, "Wrong merchant ID" - - account = await get_account(wallet.wallet.user) - assert account and account.pubkey, "Account has no Nostr pubkey" - - if account.pubkey == merchant.public_key: - return merchant # already in sync - - # Check no other merchant is using the new pubkey - existing = await get_merchant_by_pubkey(account.pubkey) - assert existing is None, ( - "Another merchant already uses this public key" - ) - - old_pubkey = merchant.public_key - - # Post-aiolabs/nostrmarket#5: re-pointing only the pubkey; the - # signing nsec lives in the bunker and is keyed on account.id, - # which is unchanged. No private_key column to update. - merchant = await update_merchant_pubkey( - wallet.wallet.user, merchant.id, account.pubkey, - ) - assert merchant - - # Republish all stalls and products under the new key - merchant = await update_merchant_to_nostr(merchant) - - logger.info( - f"[NOSTRMARKET] Migrated merchant {merchant.id} " - f"from {old_pubkey[:16]}... to {account.pubkey[:16]}..." - ) - - # Resubscribe with new pubkey - await resubscribe_to_all_merchants() - - return merchant - - except AssertionError as ex: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=str(ex), - ) from ex - except Exception as ex: - logger.warning(ex) - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Cannot migrate merchant keys", - ) from ex - - -@nostrmarket_ext.patch("/api/v1/merchant/{merchant_id}") -async def api_update_merchant( - merchant_id: str, - config: MerchantConfig, - wallet: WalletTypeInfo = Depends(require_admin_key), -): - try: - merchant = await get_merchant_for_user(wallet.wallet.user) - assert merchant, "Merchant cannot be found" - assert merchant.id == merchant_id, "Wrong merchant ID" - - updated_merchant = await update_merchant( - wallet.wallet.user, merchant_id, config - ) - return updated_merchant - - except AssertionError as ex: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=str(ex), - ) from ex - except Exception as ex: - logger.warning(ex) - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Cannot update merchant", - ) from ex - - @nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/nostr") async def api_republish_merchant( merchant_id: str, @@ -431,7 +302,7 @@ async def api_delete_merchant_on_nostr( @nostrmarket_ext.get("/api/v1/zone") async def api_get_zones( wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> List[Zone]: +) -> list[Zone]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -631,7 +502,7 @@ async def api_get_stall( @nostrmarket_ext.get("/api/v1/stall") async def api_get_stalls( - pending: Optional[bool] = False, + pending: bool | None = False, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -655,7 +526,7 @@ async def api_get_stalls( @nostrmarket_ext.get("/api/v1/stall/product/{stall_id}") async def api_get_stall_products( stall_id: str, - pending: Optional[bool] = False, + pending: bool | None = False, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -679,9 +550,9 @@ async def api_get_stall_products( @nostrmarket_ext.get("/api/v1/stall/order/{stall_id}") async def api_get_stall_orders( stall_id: str, - paid: Optional[bool] = None, - shipped: Optional[bool] = None, - pubkey: Optional[str] = None, + paid: bool | None = None, + shipped: bool | None = None, + pubkey: str | None = None, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -754,21 +625,6 @@ async def api_create_product( assert stall, "Stall missing for product" data.config.currency = stall.currency - # Re-publish the parent stall before publishing the product. NIP-33 - # parameterized replaceable events make this idempotent on relays. - # This guarantees the customer client never sees a product whose - # parent stall isn't on the relay (e.g., when the original stall - # publish failed transiently or never ran). - try: - stall_event = await sign_and_send_to_nostr(merchant, stall) - stall.event_id = stall_event.id - await update_stall(merchant.id, stall) - except Exception as ex: - logger.warning( - f"[NOSTRMARKET] Failed to refresh stall {stall.id} " - f"before product publish: {ex}" - ) - product = await create_product(merchant.id, data=data) event = await sign_and_send_to_nostr(merchant, product) @@ -830,7 +686,7 @@ async def api_update_product( async def api_get_product( product_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> Optional[Product]: +) -> Product | None: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -915,9 +771,9 @@ async def api_get_order( @nostrmarket_ext.get("/api/v1/order") async def api_get_orders( - paid: Optional[bool] = None, - shipped: Optional[bool] = None, - pubkey: Optional[str] = None, + paid: bool | None = None, + shipped: bool | None = None, + pubkey: str | None = None, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -961,11 +817,27 @@ async def api_update_order_status( ensure_ascii=False, ) - await send_dm( - merchant, - order.public_key, - DirectMessageType.ORDER_PAID_OR_SHIPPED.value, - dm_content, + dm_event = merchant.build_dm_event(dm_content, order.public_key) + + dm = PartialDirectMessage( + event_id=dm_event.id, + event_created_at=dm_event.created_at, + message=dm_content, + public_key=order.public_key, + type=DirectMessageType.ORDER_PAID_OR_SHIPPED.value, + ) + await create_direct_message(merchant.id, dm) + + await nostr_client.publish_nostr_event(dm_event) + await websocket_updater( + merchant.id, + json.dumps( + { + "type": f"dm:{dm.type}", + "customerPubkey": order.public_key, + "dm": dm.dict(), + } + ), ) return order @@ -987,7 +859,7 @@ async def api_update_order_status( async def api_restore_order( event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), -) -> Optional[Order]: +) -> Order | None: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -1114,7 +986,7 @@ async def api_reissue_order_invoice( @nostrmarket_ext.get("/api/v1/message/{public_key}") async def api_get_messages( public_key: str, wallet: WalletTypeInfo = Depends(require_invoice_key) -) -> List[DirectMessage]: +) -> list[DirectMessage]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -1143,13 +1015,14 @@ async def api_create_message( merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" - dm_reply = await send_dm( - merchant, - data.public_key, - data.type, - data.message, - ) - return dm_reply + dm_event = merchant.build_dm_event(data.message, data.public_key) + data.event_id = dm_event.id + data.event_created_at = dm_event.created_at + + dm = await create_direct_message(merchant.id, data) + await nostr_client.publish_nostr_event(dm_event) + + return dm except AssertionError as ex: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -1169,7 +1042,7 @@ async def api_create_message( @nostrmarket_ext.get("/api/v1/customer") async def api_get_customers( wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> List[Customer]: +) -> list[Customer]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found"