diff --git a/.gitignore b/.gitignore index f3a8853..056489e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ node_modules *.swp *.pyo *.pyc -*.env \ No newline at end of file +*.env + +# Claude Code config +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..db2ef06 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# 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 1839351..daa0daf 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ + + + + 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). @@ -147,3 +157,10 @@ Stall and product are _Parameterized Replaceable Events_ according to [NIP-33](h Order placing, invoicing, payment details and order statuses are handled over Nostr using [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md). Customer support is handled over whatever communication method was specified. If communicationg via nostr, [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) is used. + +## Powered by LNbits + +[LNbits](https://lnbits.com) is a free and open-source lightning accounts system. + +[![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 921c383..cffa9fa 100644 --- a/__init__.py +++ b/__init__.py @@ -27,7 +27,11 @@ def nostrmarket_renderer(): nostr_client: NostrClient = NostrClient() -from .tasks import wait_for_nostr_events, wait_for_paid_invoices # noqa +from .tasks import ( # noqa + subscription_health_monitor, + wait_for_nostr_events, + wait_for_paid_invoices, +) from .views import * # noqa from .views_api import * # noqa @@ -65,4 +69,13 @@ def nostrmarket_start(): task3 = create_permanent_unique_task( "ext_nostrmarket_wait_for_events", _wait_for_nostr_events ) - scheduled_tasks.extend([task1, task2, task3]) + + async def _health_monitor(): + # start after the subscription is active + await asyncio.sleep(20) + await subscription_health_monitor(nostr_client) + + task4 = create_permanent_unique_task( + "ext_nostrmarket_health_monitor", _health_monitor + ) + scheduled_tasks.extend([task1, task2, task3, task4]) diff --git a/config.json b/config.json index fe7456f..3a670de 100644 --- a/config.json +++ b/config.json @@ -1,11 +1,15 @@ { + "id": "nostrmarket", + "version": "1.1.0", "name": "Nostr Market", + "repo": "https://github.com/lnbits/nostrmarket", "short_description": "Nostr Webshop/market on LNbits", + "description": "", "tile": "/nostrmarket/static/images/bitcoin-shop.png", - "min_lnbits_version": "1.0.0", + "min_lnbits_version": "1.4.0", "contributors": [ { - "name": "motorina0", + "name": "Vlad Stan", "uri": "https://github.com/motorina0", "role": "Contributor" }, @@ -18,6 +22,11 @@ "name": "talvasconcelos", "uri": "https://github.com/talvasconcelos", "role": "Developer" + }, + { + "name": "BenGWeeks", + "uri": "https://github.com/BenGWeeks", + "role": "Developer" } ], "images": [ @@ -43,5 +52,9 @@ ], "description_md": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/description.md", "terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/nostrmarket/main/toc.md", - "license": "MIT" + "license": "MIT", + "paid_features": "", + "tags": ["Nostr", "Marketplace"], + "donate": "", + "hidden": false } diff --git a/crud.py b/crud.py index 17282d7..2fe8453 100644 --- a/crud.py +++ b/crud.py @@ -1,4 +1,5 @@ import json +from typing import List, Optional, Tuple from lnbits.helpers import urlsafe_short_hash @@ -22,16 +23,19 @@ 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, private_key, public_key, meta) - VALUES (:user_id, :id, :private_key, :public_key, :meta) + (user_id, id, public_key, meta) + VALUES (:user_id, :id, :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)), }, @@ -43,7 +47,7 @@ async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant: async def update_merchant( user_id: str, merchant_id: str, config: MerchantConfig -) -> Merchant | None: +) -> Optional[Merchant]: await db.execute( f""" UPDATE nostrmarket.merchants SET meta = :meta, time = {db.timestamp_now} @@ -54,7 +58,33 @@ async def update_merchant( return await get_merchant(user_id, merchant_id) -async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None: +async def update_merchant_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]: await db.execute( f""" UPDATE nostrmarket.merchants SET time = {db.timestamp_now} @@ -65,7 +95,7 @@ async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None: return await get_merchant(user_id, merchant_id) -async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None: +async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: row: dict = await db.fetchone( """SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id AND id = :id""", { @@ -77,7 +107,7 @@ async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None: return Merchant.from_row(row) if row else None -async def get_merchant_by_pubkey(public_key: str) -> Merchant | None: +async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]: row: dict = await db.fetchone( """SELECT * FROM nostrmarket.merchants WHERE public_key = :public_key""", {"public_key": public_key}, @@ -86,7 +116,7 @@ async def get_merchant_by_pubkey(public_key: str) -> Merchant | None: return Merchant.from_row(row) if row else None -async def get_merchants_ids_with_pubkeys() -> list[tuple[str, str]]: +async def get_merchants_ids_with_pubkeys() -> List[Tuple[str, str]]: rows: list[dict] = await db.fetchall( """SELECT id, public_key FROM nostrmarket.merchants""", ) @@ -94,7 +124,7 @@ async def get_merchants_ids_with_pubkeys() -> list[tuple[str, str]]: return [(row["id"], row["public_key"]) for row in rows] -async def get_merchant_for_user(user_id: str) -> Merchant | None: +async def get_merchant_for_user(user_id: str) -> Optional[Merchant]: row: dict = await db.fetchone( """SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id """, {"user_id": user_id}, @@ -137,7 +167,7 @@ async def create_zone(merchant_id: str, data: Zone) -> Zone: return zone -async def update_zone(merchant_id: str, z: Zone) -> Zone | None: +async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]: await db.execute( """ UPDATE nostrmarket.zones @@ -156,7 +186,7 @@ async def update_zone(merchant_id: str, z: Zone) -> Zone | None: return await get_zone(merchant_id, z.id) -async def get_zone(merchant_id: str, zone_id: str) -> Zone | None: +async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]: row: dict = await db.fetchone( "SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id AND id = :id", { @@ -167,7 +197,7 @@ async def get_zone(merchant_id: str, zone_id: str) -> Zone | None: return Zone.from_row(row) if row else None -async def get_zones(merchant_id: str) -> list[Zone]: +async def get_zones(merchant_id: str) -> List[Zone]: rows: list[dict] = await db.fetchall( "SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id", {"merchant_id": merchant_id}, @@ -234,7 +264,7 @@ async def create_stall(merchant_id: str, data: Stall) -> Stall: return stall -async def get_stall(merchant_id: str, stall_id: str) -> Stall | None: +async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.stalls @@ -248,7 +278,7 @@ async def get_stall(merchant_id: str, stall_id: str) -> Stall | None: return Stall.from_row(row) if row else None -async def get_stalls(merchant_id: str, pending: bool | None = False) -> list[Stall]: +async def get_stalls(merchant_id: str, pending: Optional[bool] = False) -> List[Stall]: rows: list[dict] = await db.fetchall( """ SELECT * FROM nostrmarket.stalls @@ -273,7 +303,7 @@ async def get_last_stall_update_time() -> int: return row["event_created_at"] or 0 if row else 0 -async def update_stall(merchant_id: str, stall: Stall) -> Stall | None: +async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]: await db.execute( """ UPDATE nostrmarket.stalls @@ -397,7 +427,9 @@ async def update_product(merchant_id: str, product: Product) -> Product: return updated_product -async def update_product_quantity(product_id: str, new_quantity: int) -> Product | None: +async def update_product_quantity( + product_id: str, new_quantity: int +) -> Optional[Product]: await db.execute( """ UPDATE nostrmarket.products SET quantity = :quantity @@ -412,7 +444,7 @@ async def update_product_quantity(product_id: str, new_quantity: int) -> Product return Product.from_row(row) if row else None -async def get_product(merchant_id: str, product_id: str) -> Product | None: +async def get_product(merchant_id: str, product_id: str) -> Optional[Product]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.products @@ -428,8 +460,8 @@ async def get_product(merchant_id: str, product_id: str) -> Product | None: async def get_products( - merchant_id: str, stall_id: str, pending: bool | None = False -) -> list[Product]: + merchant_id: str, stall_id: str, pending: Optional[bool] = False +) -> List[Product]: rows: list[dict] = await db.fetchall( """ SELECT * FROM nostrmarket.products @@ -442,8 +474,8 @@ async def get_products( async def get_products_by_ids( - merchant_id: str, product_ids: list[str] -) -> list[Product]: + merchant_id: str, product_ids: List[str] +) -> List[Product]: # todo: revisit keys = [] @@ -464,7 +496,7 @@ async def get_products_by_ids( return [Product.from_row(row) for row in rows] -async def get_wallet_for_product(product_id: str) -> str | None: +async def get_wallet_for_product(product_id: str) -> Optional[str]: row: dict = await db.fetchone( """ SELECT s.wallet as wallet FROM nostrmarket.products p @@ -571,7 +603,7 @@ async def create_order(merchant_id: str, o: Order) -> Order: return order -async def get_order(merchant_id: str, order_id: str) -> Order | None: +async def get_order(merchant_id: str, order_id: str) -> Optional[Order]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.orders @@ -585,7 +617,7 @@ async def get_order(merchant_id: str, order_id: str) -> Order | None: return Order.from_row(row) if row else None -async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None: +async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Order]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.orders @@ -599,7 +631,7 @@ async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None return Order.from_row(row) if row else None -async def get_orders(merchant_id: str, **kwargs) -> list[Order]: +async def get_orders(merchant_id: str, **kwargs) -> List[Order]: q = " AND ".join( [ f"{field[0]} = :{field[0]}" @@ -616,7 +648,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 {q} + WHERE merchant_id = :merchant_id {('AND ' + q) if q else ''} ORDER BY event_created_at DESC """, values, @@ -626,7 +658,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]}" @@ -640,10 +672,11 @@ 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} + WHERE merchant_id = :merchant_id AND stall_id = :stall_id {q_clause} ORDER BY time DESC """, values, @@ -651,7 +684,7 @@ async def get_orders_for_stall( return [Order.from_row(row) for row in rows] -async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | None: +async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Order]: q = ", ".join( [ f"{field[0]} = :{field[0]}" @@ -675,7 +708,7 @@ async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | Non return await get_order(merchant_id, order_id) -async def update_order_paid_status(order_id: str, paid: bool) -> Order | None: +async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]: await db.execute( "UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id", {"paid": paid, "id": order_id}, @@ -689,7 +722,7 @@ async def update_order_paid_status(order_id: str, paid: bool) -> Order | None: async def update_order_shipped_status( merchant_id: str, order_id: str, shipped: bool -) -> Order | None: +) -> Optional[Order]: await db.execute( """ UPDATE nostrmarket.orders @@ -753,7 +786,7 @@ async def create_direct_message( return msg -async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | None: +async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMessage]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.direct_messages @@ -769,7 +802,7 @@ async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | No async def get_direct_message_by_event_id( merchant_id: str, event_id: str -) -> DirectMessage | None: +) -> Optional[DirectMessage]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.direct_messages @@ -783,7 +816,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 @@ -795,7 +828,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 @@ -856,7 +889,7 @@ async def create_customer(merchant_id: str, data: Customer) -> Customer: return customer -async def get_customer(merchant_id: str, public_key: str) -> Customer | None: +async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.customers @@ -870,7 +903,7 @@ async def get_customer(merchant_id: str, public_key: str) -> Customer | None: return Customer.from_row(row) if row else None -async def get_customers(merchant_id: str) -> list[Customer]: +async def get_customers(merchant_id: str) -> List[Customer]: rows: list[dict] = await db.fetchall( "SELECT * FROM nostrmarket.customers WHERE merchant_id = :merchant_id", {"merchant_id": merchant_id}, @@ -878,7 +911,7 @@ async def get_customers(merchant_id: str) -> list[Customer]: return [Customer.from_row(row) for row in rows] -async def get_all_unique_customers() -> list[Customer]: +async def get_all_unique_customers() -> List[Customer]: q = """ SELECT public_key, MAX(merchant_id) as merchant_id, MAX(event_created_at) FROM nostrmarket.customers diff --git a/description.md b/description.md index 6446ca7..b4fc5d2 100644 --- a/description.md +++ b/description.md @@ -1,12 +1,10 @@ -> IMPORTANT: Nostr market needs the nostr-client extension installed. +Buy and sell products over Nostr using the NIP-15 marketplace protocol. -Buy and sell things over Nostr, using NIP15 https://github.com/nostr-protocol/nips/blob/master/15.md +Its functions include: -Nostr was partly based on the the previous version of this extension "Diagon Alley", so lends itself very well to buying and sellinng over Nostr. +- Managing products, sales, and customer communication as a merchant +- Browsing and ordering products as a customer +- Tracking order status and delivery +- Communicating via NIP-17 private direct messages (NIP-44 encryption + NIP-59 gift wrapping) -The Nostr Market extension includes: - -- A merchant client to manage products, sales and communication with customers. -- A customer client to find and order products from merchants, communicate with merchants and track status of ordered products. - -All communication happens over NIP04 encrypted DMs. +A decentralized commerce solution for merchants and buyers who want to trade goods and services over Nostr with end-to-end encrypted communication. diff --git a/helpers.py b/helpers.py index 478c0e1..1bc81b6 100644 --- a/helpers.py +++ b/helpers.py @@ -1,67 +1,9 @@ -import base64 -import secrets - -import coincurve from bech32 import bech32_decode, convertbits -from cryptography.hazmat.primitives import padding -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - -def get_shared_secret(privkey: str, pubkey: str): - pk = coincurve.PublicKey(bytes.fromhex("02" + pubkey)) - sk = coincurve.PrivateKey(bytes.fromhex(privkey)) - return sk.ecdh(pk.format()) - - -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}'" +# 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. def normalize_public_key(pubkey: str) -> str: diff --git a/migrations_fork.py b/migrations_fork.py new file mode 100644 index 0000000..22bf38e --- /dev/null +++ b/migrations_fork.py @@ -0,0 +1,77 @@ +""" +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 new file mode 100644 index 0000000..de393e2 --- /dev/null +++ b/misc-docs/ORDER-DISCOVERY-ANALYSIS.md @@ -0,0 +1,320 @@ +# Nostrmarket Order Discovery Analysis + +## Executive Summary + +This document analyzes the order discovery mechanism in the Nostrmarket extension and identifies why merchants must manually refresh to see new orders instead of receiving them automatically through persistent subscriptions. + +--- + +## Current Architecture + +### Two Subscription Systems + +The Nostrmarket extension implements two distinct subscription mechanisms for receiving Nostr events: + +#### 1. **Persistent Subscriptions (Background Task)** + +**Purpose**: Continuous monitoring for new orders, products, and merchant events + +**Implementation**: + +- Runs via `wait_for_nostr_events()` background task +- Initiated on extension startup (15-second delay) +- Creates subscription ID: `nostrmarket-{hash}` +- Monitors all merchant public keys continuously + +**Code Location**: `/nostrmarket/tasks.py:37-49` + +```python +async def wait_for_nostr_events(nostr_client: NostrClient): + while True: + try: + await subscribe_to_all_merchants() + while True: + message = await nostr_client.get_event() + await process_nostr_message(message) +``` + +**Subscription Filters**: + +- Direct messages (kind 4) - for orders +- Stall events (kind 30017) +- Product events (kind 30018) +- Profile updates (kind 0) + +#### 2. **Temporary Subscriptions (Manual Refresh)** + +**Purpose**: Catch up on missed events when merchant clicks "Refresh from Nostr" + +**Implementation**: + +- Duration: 10 seconds only +- Triggered by user action +- Creates subscription ID: `merchant-{hash}` +- Fetches ALL events from time=0 + +**Code Location**: `/nostrmarket/nostr/nostr_client.py:100-120` + +```python +async def merchant_temp_subscription(self, pk, duration=10): + dm_filters = self._filters_for_direct_messages([pk], 0) + # ... creates filters with time=0 (all history) + await self.send_req_queue.put(["REQ", subscription_id] + merchant_filters) + asyncio.create_task(unsubscribe_with_delay(subscription_id, duration)) +``` + +--- + +## Problem Identification + +### Why Manual Refresh is Required + +#### **Issue 1: Timing Window Problem** + +The persistent subscription uses timestamps from the last database update: + +```python +async def subscribe_to_all_merchants(): + last_dm_time = await get_last_direct_messages_created_at() + last_stall_time = await get_last_stall_update_time() + last_prod_time = await get_last_product_update_time() + + await nostr_client.subscribe_merchants( + public_keys, last_dm_time, last_stall_time, last_prod_time, 0 + ) +``` + +**Problem**: Events that occur between: + +- The last database update time +- When the subscription becomes active + ...are potentially missed + +#### **Issue 2: Connection Stability** + +The WebSocket connection between components may be unstable: + +``` +[Nostrmarket] <--WebSocket--> [Nostrclient] <--WebSocket--> [Nostr Relays] + Extension Extension (Global) +``` + +**Potential failure points**: + +1. Connection drops between nostrmarket → nostrclient +2. Connection drops between nostrclient → relays +3. Reconnection doesn't re-establish subscriptions + +#### **Issue 3: Subscription State Management** + +**Current behavior**: + +- Single persistent subscription per merchant +- No automatic resubscription on failure +- No heartbeat/keepalive mechanism +- No verification that subscription is active + +#### **Issue 4: Event Processing Delays** + +The startup sequence has intentional delays: + +```python +async def _subscribe_to_nostr_client(): + await asyncio.sleep(10) # Wait for nostrclient + await nostr_client.run_forever() + +async def _wait_for_nostr_events(): + await asyncio.sleep(15) # Wait for extension init + await wait_for_nostr_events(nostr_client) +``` + +**Problem**: Orders arriving during initialization are missed + +--- + +## Why Manual Refresh Works + +The temporary subscription succeeds because: + +1. **Fetches from time=0**: Gets ALL historical events +2. **Fresh connection**: Creates new subscription request +3. **Immediate processing**: No startup delays +4. **Direct feedback**: User sees results immediately + +```python +# Temporary subscription uses time=0 (all events) +dm_filters = self._filters_for_direct_messages([pk], 0) # ← 0 means all time + +# Persistent subscription uses last update time +dm_filters = self._filters_for_direct_messages(public_keys, dm_time) # ← can miss events +``` + +--- + +## Impact Analysis + +### User Experience Issues + +1. **Merchants miss orders** without manual refresh +2. **No real-time notifications** for new orders +3. **Uncertainty** about order status +4. **Extra manual steps** required +5. **Delayed order fulfillment** + +### Technical Implications + +1. **Not truly decentralized** - requires active monitoring +2. **Scalability concerns** - manual refresh doesn't scale +3. **Reliability issues** - depends on user action +4. **Performance overhead** - fetching all events repeatedly + +--- + +## Recommended Solutions + +### Solution A: Enhanced Persistent Subscriptions + +**Implement redundant subscription mechanisms:** + +```python +class EnhancedSubscriptionManager: + def __init__(self): + self.last_heartbeat = time.time() + self.subscription_active = False + + async def maintain_subscription(self): + while True: + if not self.subscription_active or \ + time.time() - self.last_heartbeat > 30: + await self.resubscribe_with_overlap() + await asyncio.sleep(10) + + async def resubscribe_with_overlap(self): + # Use timestamp with 5-minute overlap + overlap_time = int(time.time()) - 300 + await subscribe_to_all_merchants(since=overlap_time) +``` + +### Solution B: Periodic Auto-Refresh + +**Add automatic temporary subscriptions:** + +```python +async def auto_refresh_loop(): + while True: + await asyncio.sleep(60) # Every minute + merchants = await get_all_active_merchants() + for merchant in merchants: + await merchant_temp_subscription(merchant.pubkey, duration=5) +``` + +### Solution C: WebSocket Health Monitoring + +**Implement connection health checks:** + +```python +class WebSocketHealthMonitor: + async def check_connection_health(self): + try: + # Send ping to nostrclient + response = await nostr_client.ping(timeout=5) + if not response: + await self.reconnect_and_resubscribe() + except Exception: + await self.reconnect_and_resubscribe() +``` + +### Solution D: Event Gap Detection + +**Detect and fill gaps in event sequence:** + +```python +async def detect_event_gaps(): + # Check for gaps in event timestamps + last_known = await get_last_event_time() + current_time = int(time.time()) + + if current_time - last_known > 60: # 1 minute gap + # Perform temporary subscription to fill gap + await fetch_missing_events(since=last_known) +``` + +--- + +## Implementation Priority + +### Phase 1: Quick Fixes (1-2 days) + +1. [DONE] Increase temp subscription duration (10s → 30s) +2. [DONE] Add connection health logging +3. [DONE] Reduce startup delays + +### Phase 2: Reliability (3-5 days) + +1. [TODO] Implement subscription heartbeat +2. [TODO] Add automatic resubscription on failure +3. [TODO] Create event gap detection + +### Phase 3: Full Solution (1-2 weeks) + +1. [TODO] WebSocket connection monitoring +2. [TODO] Redundant subscription system +3. [TODO] Real-time order notifications +4. [TODO] Event deduplication logic + +--- + +## Testing Recommendations + +### Test Scenarios + +1. **Order during startup**: Send order within 15 seconds of server start +2. **Long-running test**: Keep server running for 24 hours, send periodic orders +3. **Connection interruption**: Disconnect nostrclient, send order, reconnect +4. **High volume**: Send 100 orders rapidly +5. **Network latency**: Add artificial delay between components + +### Monitoring Metrics + +- Time between order sent → order discovered +- Percentage of orders requiring manual refresh +- WebSocket connection uptime +- Subscription success rate +- Event processing latency + +--- + +## Conclusion + +The current order discovery system relies on manual refresh due to: + +1. **Timing gaps** in persistent subscriptions +2. **Connection stability** issues +3. **Lack of redundancy** in subscription management +4. **No automatic recovery** mechanisms + +While the temporary subscription (manual refresh) provides a workaround, a proper solution requires implementing connection monitoring, subscription health checks, and automatic gap-filling mechanisms to ensure reliable real-time order discovery. + +--- + +## Appendix: Code References + +### Key Files + +- `/nostrmarket/tasks.py` - Background task management +- `/nostrmarket/nostr/nostr_client.py` - Nostr client implementation +- `/nostrmarket/services.py` - Order processing logic +- `/nostrmarket/views_api.py` - API endpoints for refresh + +### Relevant Functions + +- `wait_for_nostr_events()` - Main event loop +- `subscribe_to_all_merchants()` - Persistent subscription +- `merchant_temp_subscription()` - Manual refresh +- `process_nostr_message()` - Event processing + +--- + +_Document prepared: January 2025_ +_Analysis based on: Nostrmarket v1.0_ +_Status: Active Investigation_ diff --git a/models.py b/models.py index f1af073..6a4ae3b 100644 --- a/models.py +++ b/models.py @@ -2,17 +2,11 @@ import json import time from abc import abstractmethod from enum import Enum -from typing import Any +from typing import Any, List, Optional, Tuple from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis from pydantic import BaseModel -from .helpers import ( - decrypt_message, - encrypt_message, - get_shared_secret, - sign_message_hash, -) from .nostr.event import NostrEvent ######################################## NOSTR ######################################## @@ -32,52 +26,47 @@ class Nostrable: class MerchantProfile(BaseModel): - name: str | None = None - about: str | None = None - picture: str | None = None + name: Optional[str] = None + display_name: Optional[str] = None + about: Optional[str] = None + picture: Optional[str] = None + banner: Optional[str] = None + website: Optional[str] = None + nip05: Optional[str] = None + lud16: Optional[str] = None class MerchantConfig(MerchantProfile): - event_id: str | None = None + event_id: Optional[str] = None sync_from_nostr: bool = False - active: bool = False - restore_in_progress: bool | None = False + # TODO: switched to True for AIO demo; determine if we leave this as True + active: bool = True + restore_in_progress: Optional[bool] = False + # Set at runtime (not persisted) when account keypair != merchant keypair + key_mismatch: Optional[bool] = False + + +class CreateMerchantRequest(BaseModel): + config: MerchantConfig = MerchantConfig() class PartialMerchant(BaseModel): - private_key: str public_key: str config: MerchantConfig = MerchantConfig() class Merchant(PartialMerchant, Nostrable): id: str - time: int | None = 0 + user_id: str + time: Optional[int] = 0 - def sign_hash(self, hash_: bytes) -> str: - return sign_message_hash(self.private_key, hash_) - - def decrypt_message(self, encrypted_message: str, public_key: str) -> str: - encryption_key = get_shared_secret(self.private_key, public_key) - return decrypt_message(encrypted_message, encryption_key) - - def encrypt_message(self, clear_text_message: str, public_key: str) -> str: - encryption_key = get_shared_secret(self.private_key, public_key) - return encrypt_message(clear_text_message, encryption_key) - - def build_dm_event(self, message: str, to_pubkey: str) -> NostrEvent: - content = self.encrypt_message(message, to_pubkey) - event = NostrEvent( - pubkey=self.public_key, - created_at=round(time.time()), - kind=4, - tags=[["p", to_pubkey]], - content=content, - ) - event.id = event.event_id - event.sig = self.sign_hash(bytes.fromhex(event.id)) - - return event + # 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. @classmethod def from_row(cls, row: dict) -> "Merchant": @@ -86,11 +75,23 @@ class Merchant(PartialMerchant, Nostrable): return merchant def to_nostr_event(self, pubkey: str) -> NostrEvent: - content = { - "name": self.config.name, - "about": self.config.about, - "picture": self.config.picture, - } + 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 event = NostrEvent( pubkey=pubkey, created_at=round(time.time()), @@ -122,11 +123,11 @@ class Merchant(PartialMerchant, Nostrable): ######################################## ZONES ######################################## class Zone(BaseModel): - id: str | None = None - name: str | None = None + id: Optional[str] = None + name: Optional[str] = None currency: str cost: float - countries: list[str] = [] + countries: List[str] = [] @classmethod def from_row(cls, row: dict) -> "Zone": @@ -139,22 +140,22 @@ class Zone(BaseModel): class StallConfig(BaseModel): - image_url: str | None = None - description: str | None = None + image_url: Optional[str] = None + description: Optional[str] = None class Stall(BaseModel, Nostrable): - id: str | None = None + id: Optional[str] = None wallet: str name: str currency: str = "sat" - shipping_zones: list[Zone] = [] + shipping_zones: List[Zone] = [] config: StallConfig = StallConfig() pending: bool = False """Last published nostr event for this Stall""" - event_id: str | None = None - event_created_at: int | None = None + event_id: Optional[str] = None + event_created_at: Optional[int] = None def validate_stall(self): for z in self.shipping_zones: @@ -212,19 +213,19 @@ class ProductShippingCost(BaseModel): class ProductConfig(BaseModel): - description: str | None = None - currency: str | None = None - use_autoreply: bool | None = False - autoreply_message: str | None = None - shipping: list[ProductShippingCost] = [] + description: Optional[str] = None + currency: Optional[str] = None + use_autoreply: Optional[bool] = False + autoreply_message: Optional[str] = None + shipping: List[ProductShippingCost] = [] class Product(BaseModel, Nostrable): - id: str | None = None + id: Optional[str] = None stall_id: str name: str - categories: list[str] = [] - images: list[str] = [] + categories: List[str] = [] + images: List[str] = [] price: float quantity: int active: bool = True @@ -232,8 +233,8 @@ class Product(BaseModel, Nostrable): config: ProductConfig = ProductConfig() """Last published nostr event for this Product""" - event_id: str | None = None - event_created_at: int | None = None + event_id: Optional[str] = None + event_created_at: Optional[int] = None def to_nostr_event(self, pubkey: str) -> NostrEvent: content = { @@ -290,7 +291,7 @@ class ProductOverview(BaseModel): id: str name: str price: float - product_shipping_cost: float | None = None + product_shipping_cost: Optional[float] = None @classmethod def from_product(cls, p: Product) -> "ProductOverview": @@ -307,21 +308,21 @@ class OrderItem(BaseModel): class OrderContact(BaseModel): - nostr: str | None = None - phone: str | None = None - email: str | None = None + nostr: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None class OrderExtra(BaseModel): - products: list[ProductOverview] + products: List[ProductOverview] currency: str btc_price: str shipping_cost: float = 0 shipping_cost_sat: float = 0 - fail_message: str | None = None + fail_message: Optional[str] = None @classmethod - async def from_products(cls, products: list[Product]): + async def from_products(cls, products: List[Product]): currency = products[0].config.currency if len(products) else "sat" exchange_rate = ( await btc_price(currency) if currency and currency != "sat" else 1 @@ -337,19 +338,19 @@ class OrderExtra(BaseModel): class PartialOrder(BaseModel): id: str - event_id: str | None = None - event_created_at: int | None = None + event_id: Optional[str] = None + event_created_at: Optional[int] = None public_key: str merchant_public_key: str shipping_id: str - items: list[OrderItem] - contact: OrderContact | None = None - address: str | None = None + items: List[OrderItem] + contact: Optional[OrderContact] = None + address: Optional[str] = None def validate_order(self): assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'" - def validate_order_items(self, product_list: list[Product]): + def validate_order_items(self, product_list: List[Product]): assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'" assert ( len(product_list) != 0 @@ -370,8 +371,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( @@ -400,7 +401,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]" @@ -449,7 +450,7 @@ class Order(PartialOrder): total: float paid: bool = False shipped: bool = False - time: int | None = None + time: Optional[int] = None extra: OrderExtra @classmethod @@ -463,14 +464,14 @@ class Order(PartialOrder): class OrderStatusUpdate(BaseModel): id: str - message: str | None = None - paid: bool | None = False - shipped: bool | None = None + message: Optional[str] = None + paid: Optional[bool] = False + shipped: Optional[bool] = None class OrderReissue(BaseModel): id: str - shipping_id: str | None = None + shipping_id: Optional[str] = None class PaymentOption(BaseModel): @@ -480,8 +481,8 @@ class PaymentOption(BaseModel): class PaymentRequest(BaseModel): id: str - message: str | None = None - payment_options: list[PaymentOption] + message: Optional[str] = None + payment_options: List[PaymentOption] ######################################## MESSAGE ####################################### @@ -497,16 +498,16 @@ class DirectMessageType(Enum): class PartialDirectMessage(BaseModel): - event_id: str | None = None - event_created_at: int | None = None + event_id: Optional[str] = None + event_created_at: Optional[int] = None message: str public_key: str type: int = DirectMessageType.PLAIN_TEXT.value incoming: bool = False - time: int | None = None + time: Optional[int] = None @classmethod - def parse_message(cls, msg) -> tuple[DirectMessageType, Any | None]: + def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]: try: msg_json = json.loads(msg) if "type" in msg_json: @@ -529,15 +530,15 @@ class DirectMessage(PartialDirectMessage): class CustomerProfile(BaseModel): - name: str | None = None - about: str | None = None + name: Optional[str] = None + about: Optional[str] = None class Customer(BaseModel): merchant_id: str public_key: str - event_created_at: int | None = None - profile: CustomerProfile | None = None + event_created_at: Optional[int] = None + profile: Optional[CustomerProfile] = None unread_messages: int = 0 @classmethod diff --git a/nostr/nip44.py b/nostr/nip44.py new file mode 100644 index 0000000..908ad8a --- /dev/null +++ b/nostr/nip44.py @@ -0,0 +1,180 @@ +""" +NIP-44 v2: Encrypted Payloads (Versioned) + +secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256, base64 + +Reference: https://github.com/nostr-protocol/nips/blob/master/44.md +""" + +import base64 +import hashlib +import hmac +import math +import secrets +import struct + +import coincurve +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms +from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand +from cryptography.hazmat.primitives import hashes + +VERSION = 2 +MIN_PLAINTEXT_SIZE = 1 +MAX_PLAINTEXT_SIZE = 65535 + + +def get_conversation_key(private_key_hex: str, public_key_hex: str) -> bytes: + """ + Calculate long-term conversation key between two users via ECDH + HKDF-extract. + Symmetric: get_conversation_key(a, B) == get_conversation_key(b, A) + """ + pk = coincurve.PublicKey(bytes.fromhex("02" + public_key_hex)) + sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex)) + shared_point = pk.multiply(sk.secret) + shared_x = shared_point.format(compressed=False)[1:33] + + # HKDF-extract only (not expand) with salt='nip44-v2' + conversation_key = hmac.new(b"nip44-v2", shared_x, hashlib.sha256).digest() + return conversation_key + + +def get_message_keys( + conversation_key: bytes, nonce: bytes +) -> tuple[bytes, bytes, bytes]: + """ + Derive per-message keys from conversation_key and nonce using HKDF-expand. + Returns (chacha_key, chacha_nonce, hmac_key). + """ + if len(conversation_key) != 32: + raise ValueError("invalid conversation_key length") + if len(nonce) != 32: + raise ValueError("invalid nonce length") + + keys = HKDFExpand( + algorithm=hashes.SHA256(), + length=76, + info=nonce, + ).derive(conversation_key) + + chacha_key = keys[0:32] + chacha_nonce = keys[32:44] + hmac_key = keys[44:76] + return chacha_key, chacha_nonce, hmac_key + + +def calc_padded_len(unpadded_len: int) -> int: + """Calculate padded length using power-of-two chunking.""" + if unpadded_len <= 0: + raise ValueError("invalid plaintext length") + if unpadded_len <= 32: + return 32 + next_power = 1 << (math.floor(math.log2(unpadded_len - 1)) + 1) + if next_power <= 256: + chunk = 32 + else: + chunk = next_power // 8 + return chunk * (math.floor((unpadded_len - 1) / chunk) + 1) + + +def _pad(plaintext: str) -> bytes: + """Convert plaintext string to padded byte array.""" + unpadded = plaintext.encode("utf-8") + unpadded_len = len(unpadded) + if unpadded_len < MIN_PLAINTEXT_SIZE or unpadded_len > MAX_PLAINTEXT_SIZE: + raise ValueError( + f"invalid plaintext length: {unpadded_len} " + f"(must be {MIN_PLAINTEXT_SIZE}..{MAX_PLAINTEXT_SIZE})" + ) + prefix = struct.pack(">H", unpadded_len) + padded_len = calc_padded_len(unpadded_len) + suffix = b"\x00" * (padded_len - unpadded_len) + return prefix + unpadded + suffix + + +def _unpad(padded: bytes) -> str: + """Convert padded byte array back to plaintext string.""" + unpadded_len = struct.unpack(">H", padded[0:2])[0] + unpadded = padded[2 : 2 + unpadded_len] + if ( + unpadded_len == 0 + or len(unpadded) != unpadded_len + or len(padded) != 2 + calc_padded_len(unpadded_len) + ): + raise ValueError("invalid padding") + return unpadded.decode("utf-8") + + +def _hmac_aad(key: bytes, message: bytes, aad: bytes) -> bytes: + """HMAC-SHA256 with AAD: hmac(key, aad || message).""" + if len(aad) != 32: + raise ValueError("AAD associated data must be 32 bytes") + return hmac.new(key, aad + message, hashlib.sha256).digest() + + +def _chacha20(key: bytes, nonce: bytes, data: bytes) -> bytes: + """ChaCha20 encrypt/decrypt with initial counter = 0.""" + # cryptography's ChaCha20 takes a 16-byte nonce: 4-byte counter (LE) + 12-byte nonce + full_nonce = b"\x00\x00\x00\x00" + nonce + cipher = Cipher(algorithms.ChaCha20(key, full_nonce), mode=None) + encryptor = cipher.encryptor() + return encryptor.update(data) + encryptor.finalize() + + +def _decode_payload(payload: str) -> tuple[bytes, bytes, bytes]: + """Decode base64 payload into (nonce, ciphertext, mac).""" + plen = len(payload) + if plen == 0 or payload[0] == "#": + raise ValueError("unknown version") + if plen < 132 or plen > 87472: + raise ValueError("invalid payload size") + + data = base64.b64decode(payload) + dlen = len(data) + if dlen < 99 or dlen > 65603: + raise ValueError("invalid data size") + + vers = data[0] + if vers != VERSION: + raise ValueError(f"unknown version {vers}") + + nonce = data[1:33] + ciphertext = data[33 : dlen - 32] + mac = data[dlen - 32 : dlen] + return nonce, ciphertext, mac + + +def encrypt( + plaintext: str, + conversation_key: bytes, + nonce: bytes | None = None, +) -> str: + """ + Encrypt plaintext using NIP-44 v2. + Returns base64-encoded payload. + """ + if nonce is None: + nonce = secrets.token_bytes(32) + if len(nonce) != 32: + raise ValueError("invalid nonce length") + + chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce) + padded = _pad(plaintext) + ciphertext = _chacha20(chacha_key, chacha_nonce, padded) + mac = _hmac_aad(hmac_key, ciphertext, nonce) + return base64.b64encode( + struct.pack("B", VERSION) + nonce + ciphertext + mac + ).decode("ascii") + + +def decrypt(payload: str, conversation_key: bytes) -> str: + """ + Decrypt a NIP-44 v2 base64 payload. + Returns plaintext string. + """ + nonce, ciphertext, mac = _decode_payload(payload) + chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce) + calculated_mac = _hmac_aad(hmac_key, ciphertext, nonce) + if not hmac.compare_digest(calculated_mac, mac): + raise ValueError("invalid MAC") + padded_plaintext = _chacha20(chacha_key, chacha_nonce, ciphertext) + return _unpad(padded_plaintext) diff --git a/nostr/nip59.py b/nostr/nip59.py new file mode 100644 index 0000000..19cc718 --- /dev/null +++ b/nostr/nip59.py @@ -0,0 +1,231 @@ +""" +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 a611980..dbc410e 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -1,6 +1,8 @@ import asyncio import json +import time from asyncio import Queue +from collections import OrderedDict from threading import Thread from typing import Callable, List, Optional @@ -12,6 +14,8 @@ from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash from .event import NostrEvent +MAX_SEEN_EVENTS = 1000 + class NostrClient: def __init__(self): @@ -20,6 +24,8 @@ class NostrClient: self.ws: Optional[WebSocketApp] = None self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32] self.running = False + self._seen_events: OrderedDict[str, None] = OrderedDict() + self.last_event_at: float = 0 @property def is_websocket_connected(self): @@ -31,9 +37,11 @@ class NostrClient: logger.debug(f"Connecting to websockets for 'nostrclient' extension...") relay_endpoint = encrypt_internal_message("relay", urlsafe=True) + ws_url = f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}" + on_open, on_message, on_error, on_close = self._ws_handlers() ws = WebSocketApp( - f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}", + ws_url, on_message=on_message, on_open=on_open, on_close=on_close, @@ -62,10 +70,21 @@ class NostrClient: logger.warning(ex) await asyncio.sleep(60) + def is_duplicate_event(self, event_id: str) -> bool: + """Check if an event has been seen recently. Returns True if duplicate.""" + if event_id in self._seen_events: + return True + self._seen_events[event_id] = None + if len(self._seen_events) > MAX_SEEN_EVENTS: + self._seen_events.popitem(last=False) + return False + async def get_event(self): value = await self.recieve_event_queue.get() if isinstance(value, ValueError): + logger.error(f"[NOSTRMARKET] ❌ Queue returned error: {value}") raise value + self.last_event_at = time.time() return value async def publish_nostr_event(self, e: NostrEvent): @@ -91,10 +110,6 @@ class NostrClient: self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32] await self.send_req_queue.put(["REQ", self.subscription_id] + merchant_filters) - logger.debug( - f"Subscribing to events for: {len(public_keys)} keys. New subscription id: {self.subscription_id}" - ) - async def merchant_temp_subscription(self, pk, duration=10): dm_filters = self._filters_for_direct_messages([pk], 0) stall_filters = self._filters_for_stall_events([pk], 0) @@ -135,13 +150,16 @@ class NostrClient: logger.debug(ex) def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List: - in_messages_filter = {"kinds": [4], "#p": public_keys} - out_messages_filter = {"kinds": [4], "authors": public_keys} - if since and since != 0: - in_messages_filter["since"] = since - out_messages_filter["since"] = since - - return [in_messages_filter, out_messages_filter] + # 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] def _filters_for_stall_events(self, public_keys: List[str], since: int) -> List: stall_filter = {"kinds": [30017], "authors": public_keys} @@ -175,16 +193,21 @@ class NostrClient: def _ws_handlers(self): def on_open(_): - logger.info("Connected to 'nostrclient' websocket") + logger.debug("[NOSTRMARKET DEBUG] ✅ Connected to 'nostrclient' websocket successfully") def on_message(_, message): - self.recieve_event_queue.put_nowait(message) + logger.debug(f"[NOSTRMARKET DEBUG] 📨 Received websocket message: {message[:200]}...") + try: + self.recieve_event_queue.put_nowait(message) + logger.debug(f"[NOSTRMARKET DEBUG] 📤 Message queued successfully") + except Exception as e: + logger.error(f"[NOSTRMARKET] ❌ Failed to queue message: {e}") def on_error(_, error): - logger.warning(error) + logger.warning(f"[NOSTRMARKET] ❌ Websocket error: {error}") def on_close(x, status_code, message): - logger.warning(f"Websocket closed: {x}: '{status_code}' '{message}'") + logger.warning(f"[NOSTRMARKET] 🔌 Websocket closed: {x}: '{status_code}' '{message}'") # force re-subscribe self.recieve_event_queue.put_nowait(ValueError("Websocket close.")) diff --git a/pyproject.toml b/pyproject.toml index 7a0c533..46dba55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nostrmarket" -version = "0.0.0" +version = "1.1.0" requires-python = ">=3.10,<3.13" description = "LNbits, free and open-source Lightning wallet and accounts system." authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] diff --git a/services.py b/services.py index 4039dbb..444a9d5 100644 --- a/services.py +++ b/services.py @@ -1,9 +1,12 @@ import asyncio import json +from typing import List, Optional, Tuple -from bolt11 import decode -from lnbits.core.crud import get_wallet +from lnbits.bolt11 import decode +from lnbits.core.crud import get_account, 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 @@ -11,9 +14,11 @@ from .crud import ( CustomerProfile, create_customer, create_direct_message, + create_merchant, create_order, create_product, create_stall, + create_zone, get_customer, get_last_direct_messages_created_at, get_last_product_update_time, @@ -41,6 +46,7 @@ from .models import ( DirectMessage, DirectMessageType, Merchant, + MerchantConfig, Nostrable, Order, OrderContact, @@ -48,22 +54,26 @@ from .models import ( OrderItem, OrderStatusUpdate, PartialDirectMessage, + PartialMerchant, PartialOrder, PaymentOption, PaymentRequest, Product, Stall, + Zone, ) from .nostr.event import NostrEvent +from .nostr.nip59 import unwrap_message, wrap_message async def create_new_order( merchant_public_key: str, data: PartialOrder -) -> PaymentRequest | None: +) -> Optional[PaymentRequest]: merchant = await get_merchant_by_pubkey(merchant_public_key) assert merchant, "Cannot find merchant for order!" - if await get_order(merchant.id, data.id): + existing_order = await get_order(merchant.id, data.id) + if existing_order: return None if data.event_id and await get_order_by_event_id(merchant.id, data.event_id): return None @@ -73,20 +83,24 @@ async def create_new_order( ) await create_order(merchant.id, order) - return PaymentRequest( + payment_request = PaymentRequest( id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)], message=receipt, ) + return payment_request async def build_order_with_payment( merchant_id: str, merchant_public_key: str, data: PartialOrder ): + products = await get_products_by_ids( merchant_id, [p.product_id for p in data.items] ) + data.validate_order_items(products) + shipping_zone = await get_zone(merchant_id, data.shipping_id) assert shipping_zone, f"Shipping zone not found for order '{data.id}'" @@ -94,6 +108,7 @@ async def build_order_with_payment( product_cost_sat, shipping_cost_sat = await data.costs_in_sats( products, shipping_zone.id, shipping_zone.cost ) + receipt = data.receipt(products, shipping_zone.id, shipping_zone.cost) wallet_id = await get_wallet_for_product(data.items[0].product_id) @@ -104,11 +119,13 @@ async def build_order_with_payment( merchant_id, product_ids, data.items ) if not success: + logger.error(f"[NOSTRMARKET] ❌ Product quantity check failed: {message}") raise ValueError(message) + total_amount_sat = round(product_cost_sat + shipping_cost_sat) payment = await create_invoice( wallet_id=wallet_id, - amount=round(product_cost_sat + shipping_cost_sat), + amount=total_amount_sat, memo=f"Order '{data.id}' for pubkey '{data.public_key}'", extra={ "tag": "nostrmarket", @@ -136,7 +153,7 @@ async def update_merchant_to_nostr( merchant: Merchant, delete_merchant=False ) -> Merchant: stalls = await get_stalls(merchant.id) - event: NostrEvent | None = None + event: Optional[NostrEvent] = None for stall in stalls: assert stall.id products = await get_products(merchant.id, stall.id) @@ -149,28 +166,180 @@ async def update_merchant_to_nostr( stall.event_id = event.id stall.event_created_at = event.created_at await update_stall(merchant.id, stall) - if delete_merchant: - # merchant profile updates not supported yet - event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant) + # Always publish merchant profile (kind 0) + 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) ) - event.sig = merchant.sign_hash(bytes.fromhex(event.id)) + + 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"] 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) @@ -221,7 +390,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 @@ -262,19 +431,37 @@ async def send_dm( other_pubkey: str, type_: int, dm_content: str, -): - dm_event = merchant.build_dm_event(dm_content, other_pubkey) +) -> 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 = PartialDirectMessage( - event_id=dm_event.id, - event_created_at=dm_event.created_at, + event_id=gift_wrap.id, + event_created_at=gift_wrap.created_at, message=dm_content, public_key=other_pubkey, type=type_, ) dm_reply = await create_direct_message(merchant.id, dm) - await nostr_client.publish_nostr_event(dm_event) + await nostr_client.publish_nostr_event(gift_wrap) + + # Also wrap a copy to self for archival + self_wrap = await wrap_message( + dm_content, + signer, + merchant.public_key, + ) + await nostr_client.publish_nostr_event(self_wrap) await websocket_updater( merchant.id, @@ -287,11 +474,13 @@ async def send_dm( ), ) + return dm_reply + async def compute_products_new_quantity( - merchant_id: str, product_ids: list[str], items: list[OrderItem] -) -> tuple[bool, list[Product], str]: - products: list[Product] = await get_products_by_ids(merchant_id, product_ids) + merchant_id: str, product_ids: List[str], items: List[OrderItem] +) -> Tuple[bool, List[Product], str]: + products: List[Product] = await get_products_by_ids(merchant_id, product_ids) for p in products: required_quantity = next( @@ -314,23 +503,38 @@ async def compute_products_new_quantity( async def process_nostr_message(msg: str): try: - type_, *rest = json.loads(msg) + parsed_msg = json.loads(msg) + type_, *rest = parsed_msg + if type_.upper() == "EVENT": + if len(rest) < 2: + logger.warning(f"[NOSTRMARKET] ⚠️ EVENT message missing data: {rest}") + return _, event = rest event = NostrEvent(**event) + + # Deduplicate events (overlap resubscriptions may deliver duplicates) + if nostr_client.is_duplicate_event(event.id): + return + if event.kind == 0: await _handle_customer_profile_update(event) - elif event.kind == 4: - await _handle_nip04_message(event) + elif event.kind == 1059: + await _handle_gift_wrap(event) elif event.kind == 30017: await _handle_stall(event) elif event.kind == 30018: await _handle_product(event) + else: + logger.info(f"[NOSTRMARKET] ❓ Unhandled event kind: {event.kind} - event: {event.id}") return + else: + logger.info(f"[NOSTRMARKET] 🔄 Non-EVENT message type: {type_}") except Exception as ex: - logger.debug(ex) + logger.error(f"[NOSTRMARKET] ❌ Error processing nostr message: {ex}") + logger.error(f"[NOSTRMARKET] 📄 Raw message that failed: {msg}") async def create_or_update_order_from_dm( @@ -411,29 +615,42 @@ async def extract_customer_order_from_dm( return order -async def _handle_nip04_message(event: NostrEvent): - merchant_public_key = event.pubkey - merchant = await get_merchant_by_pubkey(merchant_public_key) +async def _handle_gift_wrap(event: NostrEvent): + """Handle an incoming kind 1059 gift wrap event (NIP-59/NIP-17).""" + p_tags = event.tag_values("p") + if not p_tags: + logger.warning(f"[NOSTRMARKET] ⚠️ Gift wrap has no p-tag: {event.id}") + return + + # The p-tag identifies the recipient of the gift wrap + recipient_pubkey = p_tags[0] + merchant = await get_merchant_by_pubkey(recipient_pubkey) if not merchant: - p_tags = event.tag_values("p") - if len(p_tags) and p_tags[0]: - merchant_public_key = p_tags[0] - merchant = await get_merchant_by_pubkey(merchant_public_key) - - assert merchant, f"Merchant not found for public key '{merchant_public_key}'" - - if event.pubkey == merchant_public_key: - assert len(event.tag_values("p")) != 0, "Outgong message has no 'p' tag" - clear_text_msg = merchant.decrypt_message( - event.content, event.tag_values("p")[0] + logger.warning( + f"[NOSTRMARKET] ⚠️ No merchant found for gift wrap recipient: {recipient_pubkey}" ) - await _handle_outgoing_dms(event, merchant, clear_text_msg) - elif event.has_tag_value("p", merchant_public_key): - clear_text_msg = merchant.decrypt_message(event.content, event.pubkey) - await _handle_incoming_dms(event, merchant, clear_text_msg) - else: - logger.warning(f"Bad NIP04 event: '{event.id}'") + return + + try: + 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) async def _handle_incoming_dms( @@ -483,17 +700,18 @@ async def _handle_outgoing_dms( async def _handle_incoming_structured_dm( merchant: Merchant, dm: DirectMessage, json_data: dict -) -> tuple[DirectMessageType, str | None]: +) -> Tuple[DirectMessageType, Optional[str]]: try: if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active: json_resp = await _handle_new_order( merchant.id, merchant.public_key, dm, json_data ) - return DirectMessageType.PAYMENT_REQUEST, json_resp + else: + logger.info(f"[NOSTRMARKET] Skipping order processing - type: {dm.type}, expected: {DirectMessageType.CUSTOMER_ORDER.value}, merchant_active: {merchant.config.active}") except Exception as ex: - logger.warning(ex) + logger.error(f"[NOSTRMARKET] Error in _handle_incoming_structured_dm: {ex}") return DirectMessageType.PLAIN_TEXT, None @@ -532,16 +750,21 @@ async def _persist_dm( async def reply_to_structured_dm( merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str ): - dm_event = merchant.build_dm_event(dm_reply, customer_pubkey) + signer = await _resolve_merchant_signer(merchant) + gift_wrap = await wrap_message( + dm_reply, + signer, + customer_pubkey, + ) dm = PartialDirectMessage( - event_id=dm_event.id, - event_created_at=dm_event.created_at, + event_id=gift_wrap.id, + event_created_at=gift_wrap.created_at, message=dm_reply, public_key=customer_pubkey, type=dm_type, ) await create_direct_message(merchant.id, dm) - await nostr_client.publish_nostr_event(dm_event) + await nostr_client.publish_nostr_event(gift_wrap) await websocket_updater( merchant.id, @@ -574,9 +797,31 @@ async def _handle_new_order( wallet = await get_wallet(wallet_id) assert wallet, f"Cannot find wallet for product id: {first_product_id}" + payment_req = await create_new_order(merchant_public_key, partial_order) + + if payment_req is None: + # Return existing order data instead of creating a failed order + existing_order = await get_order(merchant_id, partial_order.id) + if existing_order and existing_order.invoice_id != "None": + # Order exists with invoice, return existing payment request + duplicate_response = json.dumps({ + "type": DirectMessageType.PAYMENT_REQUEST.value, + "id": existing_order.id, + "message": "Order already received and processed", + "payment_options": [] + }, separators=(",", ":"), ensure_ascii=False) + return duplicate_response + else: + # Order exists but no invoice, skip processing + logger.info(f"[NOSTRMARKET] Order exists but no invoice, returning empty string") + return "" + except Exception as e: - logger.debug(e) + logger.error(f"[NOSTRMARKET] Error creating order: {e}") + logger.error(f"[NOSTRMARKET] Order data: {json_data}") + logger.error(f"[NOSTRMARKET] Exception type: {type(e).__name__}") + logger.error(f"[NOSTRMARKET] Exception details: {str(e)}") payment_req = await create_new_failed_order( merchant_id, merchant_public_key, @@ -584,12 +829,17 @@ async def _handle_new_order( json_data, "Order received, but cannot be processed. Please contact merchant.", ) - assert payment_req + + if not payment_req: + logger.error(f"[NOSTRMARKET] No payment request returned for order: {partial_order.id}") + return "" + response = { "type": DirectMessageType.PAYMENT_REQUEST.value, **payment_req.dict(), } - return json.dumps(response, separators=(",", ":"), ensure_ascii=False) + response_json = json.dumps(response, separators=(",", ":"), ensure_ascii=False) + return response_json async def create_new_failed_order( @@ -622,8 +872,11 @@ async def subscribe_to_all_merchants(): last_stall_time = await get_last_stall_update_time() last_prod_time = await get_last_product_update_time() + # Make dm_time more lenient by subtracting 5 minutes to avoid missing recent events + lenient_dm_time = max(0, last_dm_time - 300) if last_dm_time > 0 else 0 + await nostr_client.subscribe_merchants( - public_keys, last_dm_time, last_stall_time, last_prod_time, 0 + public_keys, lenient_dm_time, last_stall_time, last_prod_time, 0 ) diff --git a/static/components/edit-profile-dialog.js b/static/components/edit-profile-dialog.js new file mode 100644 index 0000000..0736695 --- /dev/null +++ b/static/components/edit-profile-dialog.js @@ -0,0 +1,91 @@ +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 deleted file mode 100644 index 5bf9d23..0000000 --- a/static/components/key-pair.js +++ /dev/null @@ -1,22 +0,0 @@ -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 new file mode 100644 index 0000000..d993bd4 --- /dev/null +++ b/static/components/merchant-tab.js @@ -0,0 +1,102 @@ +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 new file mode 100644 index 0000000..81c451f --- /dev/null +++ b/static/components/nostr-keys-dialog.js @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..78379ae --- /dev/null +++ b/static/components/product-list.js @@ -0,0 +1,261 @@ +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 new file mode 100644 index 0000000..d93b26c --- /dev/null +++ b/static/components/shipping-zones-list.js @@ -0,0 +1,209 @@ +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 742021a..9332d1e 100644 --- a/static/components/shipping-zones.js +++ b/static/components/shipping-zones.js @@ -19,7 +19,6 @@ window.app.component('shipping-zones', { currencies: [], shippingZoneOptions: [ 'Free (digital)', - 'Flat rate', 'Worldwide', 'Europe', 'Australia', @@ -27,6 +26,7 @@ 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', - 'Ireland', 'Indonesia', + 'Ireland', 'Israel', 'Italy', 'Japan', @@ -59,10 +59,9 @@ window.app.component('shipping-zones', { 'Thailand', 'Turkey', 'Ukraine', - 'United Kingdom**', - 'United States***', - 'Vietnam', - 'China' + 'United Kingdom', + 'United States', + 'Vietnam' ] } }, @@ -162,22 +161,13 @@ window.app.component('shipping-zones', { LNbits.utils.notifyApiError(error) } }, - 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) - } + getCurrencies() { + const currencies = window.g.allowedCurrencies || [] + this.currencies = ['sat', ...currencies] } }, created: async function () { await this.getZones() - await this.getCurrencies() + this.getCurrencies() } }) diff --git a/static/components/stall-list.js b/static/components/stall-list.js index 1ef4d70..220b5c1 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: '', - align: 'left', - label: '', - field: '' - }, - { - name: 'id', - align: 'left', - label: 'Name', - field: 'id' - }, + {name: 'name', align: 'left', label: 'Name', field: 'name'}, { name: 'currency', align: 'left', @@ -45,14 +45,15 @@ window.app.component('stall-list', { name: 'description', align: 'left', label: 'Description', - field: 'description' + field: row => row.config?.description || '' }, { name: 'shippingZones', align: 'left', label: 'Shipping Zones', - field: 'shippingZones' - } + field: row => row.shipping_zones?.map(z => z.name).join(', ') || '' + }, + {name: 'actions', align: 'right', label: 'Actions', field: ''} ], pagination: { rowsPerPage: 10 @@ -65,9 +66,17 @@ 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, @@ -94,8 +103,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!' @@ -114,8 +123,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!' @@ -124,44 +133,68 @@ window.app.component('stall-list', { LNbits.utils.notifyApiError(error) } }, - deleteStall: async function (pendingStall) { - LNbits.utils - .confirmDialog( - ` - Are you sure you want to delete this pending stall '${pendingStall.name}'? - ` - ) - .onOk(async () => { - 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: async function () { + 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( - 'GET', - '/nostrmarket/api/v1/currencies', - this.inkey + 'PUT', + `/nostrmarket/api/v1/stall/${stallData.id}`, + this.adminkey, + stallData ) - - return ['sat', ...data] + 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) } - return [] + }, + 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) { + LNbits.utils + .confirmDialog( + `Products and orders will be deleted also! Are you sure you want to delete stall "${stall.name}"?` + ) + .onOk(async () => { + await this.deleteStall(stall) + }) + }, + getCurrencies: function () { + const currencies = window.g.allowedCurrencies || [] + return ['sat', ...currencies] }, getStalls: async function (pending = false) { try { @@ -170,7 +203,7 @@ window.app.component('stall-list', { `/nostrmarket/api/v1/stall?pending=${pending}`, this.inkey ) - return data.map(s => ({...s, expanded: false})) + return data } catch (error) { LNbits.utils.notifyApiError(error) } @@ -194,20 +227,8 @@ 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 = await this.getCurrencies() + this.currencies = this.getCurrencies() this.zoneOptions = await this.getZones() if (!this.zoneOptions || !this.zoneOptions.length) { this.$q.notify({ @@ -225,6 +246,24 @@ 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) @@ -246,8 +285,11 @@ window.app.component('stall-list', { })) }) }, - customerSelectedForOrder: function (customerPubkey) { - this.$emit('customer-selected-for-order', customerPubkey) + goToProducts: function (stall) { + this.$emit('go-to-products', stall.id) + }, + goToOrders: function (stall) { + this.$emit('go-to-orders', stall.id) }, shortLabel(value = '') { if (value.length <= 64) return value @@ -256,7 +298,8 @@ window.app.component('stall-list', { }, created: async function () { this.stalls = await this.getStalls() - this.currencies = await this.getCurrencies() + this.emitStallCount() + this.currencies = this.getCurrencies() this.zoneOptions = await this.getZones() } }) diff --git a/static/images/generate_logo.py b/static/images/generate_logo.py new file mode 100644 index 0000000..cb66c59 --- /dev/null +++ b/static/images/generate_logo.py @@ -0,0 +1,123 @@ +#!/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 new file mode 100644 index 0000000..3e924b5 Binary files /dev/null and b/static/images/nostr-market.png differ diff --git a/static/js/index.js b/static/js/index.js index 3d89779..f5d2e62 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -5,45 +5,60 @@ window.app = Vue.createApp({ mixins: [window.windowMixin], data: function () { return { + activeTab: 'orders', + selectedStallFilter: null, merchant: {}, shippingZones: [], activeChatCustomer: '', orderPubkey: null, showKeys: false, - importKeyDialog: { - show: false, - data: { - privateKey: null - } - }, - wsConnection: null + 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' } }, methods: { - 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}` + 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) + } }) - } - await this.createMerchant(privateKey) - }, - showImportKeysDialog: async function () { - this.importKeyDialog.show = true }, toggleShowKeys: function () { this.showKeys = !this.showKeys @@ -93,13 +108,11 @@ window.app = Vue.createApp({ this.shippingZones = [] this.activeChatCustomer = '' this.showKeys = false + this.stallCount = 0 }, - createMerchant: async function (privateKey) { + createMerchant: async function () { try { - const pubkey = nostr.getPublicKey(privateKey) const payload = { - private_key: privateKey, - public_key: pubkey, config: {} } const {data} = await LNbits.api.request( @@ -196,10 +209,132 @@ 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 nostrcient extension?' + '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?' ) .onOk(async () => { try { @@ -208,14 +343,42 @@ 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 () { - await this.getMerchant() + const merchant = await this.getMerchant() + if (!merchant) { + // Auto-create merchant using the account's existing Nostr keypair + await this.createMerchant() + } + await this.checkNostrStatus() setInterval(async () => { if ( !this.wsConnection || diff --git a/static/market/js/utils.js b/static/market/js/utils.js index 2e41b49..8a3a98b 100644 --- a/static/market/js/utils.js +++ b/static/market/js/utils.js @@ -1,5 +1,43 @@ var NostrTools = window.NostrTools +;(function ensureRandomUUID() { + if (!globalThis.crypto) { + globalThis.crypto = {} + } + if (!globalThis.crypto.randomUUID) { + globalThis.crypto.randomUUID = function () { + const getRandomValues = globalThis.crypto.getRandomValues + if (getRandomValues) { + const bytes = new Uint8Array(16) + getRandomValues.call(globalThis.crypto, bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, b => + b.toString(16).padStart(2, '0') + ).join('') + return ( + hex.slice(0, 8) + + '-' + + hex.slice(8, 12) + + '-' + + hex.slice(12, 16) + + '-' + + hex.slice(16, 20) + + '-' + + hex.slice(20) + ) + } + + let d = Date.now() + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (d + Math.random() * 16) % 16 | 0 + d = Math.floor(d / 16) + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16) + }) + } + } +})() + var defaultRelays = [ 'wss://relay.damus.io', 'wss://relay.snort.social', @@ -44,13 +82,24 @@ function confirm(message) { async function hash(string) { - const utf8 = new TextEncoder().encode(string) - const hashBuffer = await crypto.subtle.digest('SHA-256', utf8) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - const hashHex = hashArray - .map(bytes => bytes.toString(16).padStart(2, '0')) - .join('') - return hashHex + const subtle = globalThis.crypto && globalThis.crypto.subtle + if (subtle && subtle.digest) { + const utf8 = new TextEncoder().encode(string) + const hashBuffer = await subtle.digest('SHA-256', utf8) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map(bytes => bytes.toString(16).padStart(2, '0')).join('') + } + + // Fallback for non-secure contexts where crypto.subtle is unavailable. + return fallbackHash(string) +} + +function fallbackHash(string) { + let hash = 5381 + for (let i = 0; i < string.length; i++) { + hash = ((hash << 5) + hash) + string.charCodeAt(i) + } + return (hash >>> 0).toString(16).padStart(8, '0') } function isJson(str) { diff --git a/tasks.py b/tasks.py index 013a281..c147936 100644 --- a/tasks.py +++ b/tasks.py @@ -1,4 +1,5 @@ import asyncio +import time from asyncio import Queue from lnbits.core.models import Payment @@ -9,9 +10,13 @@ from .nostr.nostr_client import NostrClient from .services import ( handle_order_paid, process_nostr_message, + resubscribe_to_all_merchants, subscribe_to_all_merchants, ) +HEALTH_CHECK_INTERVAL = 30 # seconds between health checks +STALE_THRESHOLD = 120 # seconds without events before resubscribing + async def wait_for_paid_invoices(): invoice_queue = Queue() @@ -35,13 +40,38 @@ async def on_invoice_paid(payment: Payment) -> None: async def wait_for_nostr_events(nostr_client: NostrClient): + logger.info("[NOSTRMARKET] Starting wait_for_nostr_events task") while True: try: + logger.info("[NOSTRMARKET] Subscribing to all merchants...") await subscribe_to_all_merchants() while True: message = await nostr_client.get_event() await process_nostr_message(message) except Exception as e: - logger.warning(f"Subcription failed. Will retry in one minute: {e}") + logger.warning(f"[NOSTRMARKET] Subscription failed. Retrying in 10s: {e}") await asyncio.sleep(10) + + +async def subscription_health_monitor(nostr_client: NostrClient): + """ + Periodically check if events are flowing. If no events have been + received for STALE_THRESHOLD seconds, force a resubscription with + overlap to catch any missed events. + """ + logger.info("[NOSTRMARKET] Starting subscription health monitor") + while True: + await asyncio.sleep(HEALTH_CHECK_INTERVAL) + try: + if not nostr_client.is_websocket_connected: + continue + + elapsed = time.time() - nostr_client.last_event_at + if nostr_client.last_event_at > 0 and elapsed > STALE_THRESHOLD: + logger.warning( + f"[NOSTRMARKET] ⚠️ No events for {elapsed:.0f}s, resubscribing..." + ) + await resubscribe_to_all_merchants() + except Exception as e: + logger.error(f"[NOSTRMARKET] Health monitor error: {e}") diff --git a/templates/nostrmarket/_api_docs.html b/templates/nostrmarket/_api_docs.html index 6bce480..664d6ba 100644 --- a/templates/nostrmarket/_api_docs.html +++ b/templates/nostrmarket/_api_docs.html @@ -1,44 +1,213 @@ - - -

- Nostr Market
- - Created by, + + + +

+ 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:

+
Tal Vasconcelos - Ben Arc + Tal Vasconcelos + + Ben Arc + + motorina0 -

- Swagger REST API Documentation - - - Visit the market clientMarket client - - + target="_blank" + class="text-decoration-none" + > + 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 + + + + + diff --git a/templates/nostrmarket/components/direct-messages.html b/templates/nostrmarket/components/direct-messages.html index 9f68511..602eb8b 100644 --- a/templates/nostrmarket/components/direct-messages.html +++ b/templates/nostrmarket/components/direct-messages.html @@ -1,143 +1,147 @@
- -
-
-
Messages
-
-
-   new -
-
- Client Orders -
-
-
- - - - -
-
- - - -
-
- - Add a public key to chat with - -
-
-
- -
-
-
- + +
+
+   new +
+
+ Client Orders -
-
- New order: -
-
- Reply sent for order: -
-
- Paid - Shipped - -
-
- - - - -
- ... -
-
-
- - - + +
+
+ - + + + - + + + + + {% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..22ffb83 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +""" +Stub out the nostrmarket root package and all LNbits dependencies so that +nostr/* unit tests can run without the full LNbits environment. + +pytest walks up from tests/ and tries to import the parent __init__.py, +which pulls in fastapi, lnbits, websocket, etc. We preemptively register +the parent package as a simple module so that import never happens. +""" + +import sys +import types +from pathlib import Path + +# Register 'nostrmarket' as an already-imported namespace package +# pointing at the extension root, so pytest doesn't try to exec __init__.py +_ext_root = Path(__file__).resolve().parent.parent +_pkg = types.ModuleType("nostrmarket") +_pkg.__path__ = [str(_ext_root)] +_pkg.__package__ = "nostrmarket" +sys.modules["nostrmarket"] = _pkg + +# Also ensure the nostr subpackage is importable +_nostr_dir = _ext_root / "nostr" +_nostr_pkg = types.ModuleType("nostrmarket.nostr") +_nostr_pkg.__path__ = [str(_nostr_dir)] +_nostr_pkg.__package__ = "nostrmarket.nostr" +sys.modules["nostrmarket.nostr"] = _nostr_pkg diff --git a/tests/test_nip44.py b/tests/test_nip44.py new file mode 100644 index 0000000..3e767a6 --- /dev/null +++ b/tests/test_nip44.py @@ -0,0 +1,139 @@ +"""Tests for NIP-44 v2 encryption against official spec test vectors.""" + +import coincurve +import pytest + +from nostr.nip44 import ( + calc_padded_len, + decrypt, + encrypt, + get_conversation_key, + get_message_keys, +) + + +def pubkey_from_secret(secret_hex: str) -> str: + """Derive x-only public key hex from secret key hex.""" + sk = coincurve.PrivateKey(bytes.fromhex(secret_hex)) + return sk.public_key.format(compressed=True)[1:].hex() + + +# --- Test vector from NIP-44 spec --- + +SPEC_VECTOR = { + "sec1": "0000000000000000000000000000000000000000000000000000000000000001", + "sec2": "0000000000000000000000000000000000000000000000000000000000000002", + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "plaintext": "a", + "payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb", +} + + +class TestConversationKey: + def test_spec_vector(self): + pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) + key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) + assert key.hex() == SPEC_VECTOR["conversation_key"] + + def test_symmetric(self): + """conv(a, B) == conv(b, A)""" + pub1 = pubkey_from_secret(SPEC_VECTOR["sec1"]) + pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) + key_ab = get_conversation_key(SPEC_VECTOR["sec1"], pub2) + key_ba = get_conversation_key(SPEC_VECTOR["sec2"], pub1) + assert key_ab == key_ba + + +class TestMessageKeys: + def test_returns_correct_lengths(self): + conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) + nonce = bytes.fromhex(SPEC_VECTOR["nonce"]) + chacha_key, chacha_nonce, hmac_key = get_message_keys(conv_key, nonce) + assert len(chacha_key) == 32 + assert len(chacha_nonce) == 12 + assert len(hmac_key) == 32 + + def test_rejects_bad_key_length(self): + with pytest.raises(ValueError): + get_message_keys(b"\x00" * 16, b"\x00" * 32) + + def test_rejects_bad_nonce_length(self): + with pytest.raises(ValueError): + get_message_keys(b"\x00" * 32, b"\x00" * 16) + + +class TestPadding: + @pytest.mark.parametrize( + "unpadded,expected", + [ + (1, 32), + (2, 32), + (31, 32), + (32, 32), + (33, 64), + (64, 64), + (65, 96), + (256, 256), + (257, 320), + (1024, 1024), + (65535, 65536), + ], + ) + def test_calc_padded_len(self, unpadded, expected): + assert calc_padded_len(unpadded) == expected + + def test_rejects_zero(self): + with pytest.raises(ValueError): + calc_padded_len(0) + + +class TestEncryptDecrypt: + def test_spec_vector(self): + conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) + nonce = bytes.fromhex(SPEC_VECTOR["nonce"]) + payload = encrypt(SPEC_VECTOR["plaintext"], conv_key, nonce) + assert payload == SPEC_VECTOR["payload"] + + def test_spec_vector_decrypt(self): + conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) + plaintext = decrypt(SPEC_VECTOR["payload"], conv_key) + assert plaintext == SPEC_VECTOR["plaintext"] + + def test_round_trip_short(self): + pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) + conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) + msg = "x" + assert decrypt(encrypt(msg, conv_key), conv_key) == msg + + def test_round_trip_long(self): + pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) + conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) + msg = "A" * 65535 + assert decrypt(encrypt(msg, conv_key), conv_key) == msg + + def test_round_trip_unicode(self): + pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) + conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) + msg = "hello world! \U0001f680\U0001f30e\U0001f4ac" + assert decrypt(encrypt(msg, conv_key), conv_key) == msg + + def test_tampered_mac_rejected(self): + conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) + payload = SPEC_VECTOR["payload"] + tampered = payload[:-1] + ("a" if payload[-1] != "a" else "b") + with pytest.raises(ValueError, match="invalid MAC"): + decrypt(tampered, conv_key) + + def test_empty_plaintext_rejected(self): + conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) + with pytest.raises(ValueError, match="invalid plaintext length"): + encrypt("", conv_key) + + def test_unknown_version_rejected(self): + with pytest.raises(ValueError, match="unknown version"): + decrypt("#invalid", bytes(32)) + + def test_short_payload_rejected(self): + with pytest.raises(ValueError, match="invalid payload size"): + decrypt("AAAA", bytes(32)) diff --git a/tests/test_nip59.py b/tests/test_nip59.py new file mode 100644 index 0000000..5751990 --- /dev/null +++ b/tests/test_nip59.py @@ -0,0 +1,258 @@ +"""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/uv.lock b/uv.lock index 60799b1..ef945f8 100644 --- a/uv.lock +++ b/uv.lock @@ -847,6 +847,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, { url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/f1/29/74242b7d72385e29bcc5563fba67dad94943d7cd03552bac320d597f29b2/greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7", size = 1544904, upload-time = "2025-11-04T12:42:04.763Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e2/1572b8eeab0f77df5f6729d6ab6b141e4a84ee8eb9bc8c1e7918f94eda6d/greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8", size = 1611228, upload-time = "2025-11-04T12:42:08.423Z" }, { url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" }, { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, @@ -856,6 +858,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" }, + { url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" }, { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, @@ -865,6 +869,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, ] @@ -1322,7 +1328,7 @@ wheels = [ [[package]] name = "nostrmarket" -version = "0.0.0" +version = "1.1.0" source = { virtual = "." } dependencies = [ { name = "lnbits" }, diff --git a/views_api.py b/views_api.py index af675cd..550cca1 100644 --- a/views_api.py +++ b/views_api.py @@ -1,11 +1,13 @@ import json from http import HTTPStatus +from typing import List, Optional from fastapi import Depends from fastapi.exceptions import HTTPException -from lnbits.core.models import WalletTypeInfo +from lnbits.core.crud import get_account from lnbits.core.services import websocket_updater from lnbits.decorators import ( + WalletTypeInfo, require_admin_key, require_invoice_key, ) @@ -36,6 +38,7 @@ 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, @@ -58,10 +61,12 @@ from .crud import ( ) from .helpers import normalize_public_key from .models import ( + CreateMerchantRequest, Customer, DirectMessage, DirectMessageType, Merchant, + MerchantConfig, Order, OrderReissue, OrderStatusUpdate, @@ -77,8 +82,10 @@ from .models import ( from .services import ( build_order_with_payment, create_or_update_order_from_dm, + provision_merchant, reply_to_structured_dm, resubscribe_to_all_merchants, + send_dm, sign_and_send_to_nostr, subscribe_to_all_merchants, update_merchant_to_nostr, @@ -87,37 +94,55 @@ 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: PartialMerchant, + data: CreateMerchantRequest, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> Merchant: try: - merchant = await get_merchant_by_pubkey(data.public_key) - assert merchant is None, "A merchant already uses this public key" - merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant is None, "A merchant already exists for this user" - merchant = await create_merchant(wallet.wallet.user, data) - - await create_zone( - merchant.id, - Zone( - id=f"online-{merchant.public_key}", - name="Online", - currency="sat", - cost=0, - countries=["Free (digital)"], - ), - ) - - await resubscribe_to_all_merchants() - - await nostr_client.merchant_temp_subscription(data.public_key) - - return merchant + return await _auto_create_merchant(wallet, data.config) except AssertionError as ex: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -134,12 +159,13 @@ async def api_create_merchant( @nostrmarket_ext.get("/api/v1/merchant") async def api_get_merchant( wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> Merchant | None: +) -> Merchant: try: merchant = await get_merchant_for_user(wallet.wallet.user) if not merchant: - return None + # Auto-provision merchant from the user's account keypair + merchant = await _auto_create_merchant(wallet) merchant = await touch_merchant(wallet.wallet.user, merchant.id) assert merchant @@ -147,6 +173,11 @@ async def api_get_merchant( assert merchant.time merchant.config.restore_in_progress = (merchant.time - last_dm_time) < 30 + # Detect keypair rotation: account key no longer matches merchant key + account = await get_account(wallet.wallet.user) + if account and account.pubkey and account.pubkey != merchant.public_key: + merchant.config.key_mismatch = True + return merchant except Exception as ex: logger.warning(ex) @@ -192,6 +223,104 @@ 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, @@ -302,7 +431,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" @@ -502,7 +631,7 @@ async def api_get_stall( @nostrmarket_ext.get("/api/v1/stall") async def api_get_stalls( - pending: bool | None = False, + pending: Optional[bool] = False, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -526,7 +655,7 @@ async def api_get_stalls( @nostrmarket_ext.get("/api/v1/stall/product/{stall_id}") async def api_get_stall_products( stall_id: str, - pending: bool | None = False, + pending: Optional[bool] = False, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -550,9 +679,9 @@ async def api_get_stall_products( @nostrmarket_ext.get("/api/v1/stall/order/{stall_id}") async def api_get_stall_orders( stall_id: str, - paid: bool | None = None, - shipped: bool | None = None, - pubkey: str | None = None, + paid: Optional[bool] = None, + shipped: Optional[bool] = None, + pubkey: Optional[str] = None, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -625,6 +754,21 @@ async def api_create_product( assert stall, "Stall missing for product" data.config.currency = stall.currency + # Re-publish the parent stall before publishing the product. NIP-33 + # parameterized replaceable events make this idempotent on relays. + # This guarantees the customer client never sees a product whose + # parent stall isn't on the relay (e.g., when the original stall + # publish failed transiently or never ran). + try: + stall_event = await sign_and_send_to_nostr(merchant, stall) + stall.event_id = stall_event.id + await update_stall(merchant.id, stall) + except Exception as ex: + logger.warning( + f"[NOSTRMARKET] Failed to refresh stall {stall.id} " + f"before product publish: {ex}" + ) + product = await create_product(merchant.id, data=data) event = await sign_and_send_to_nostr(merchant, product) @@ -686,7 +830,7 @@ async def api_update_product( async def api_get_product( product_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> Product | None: +) -> Optional[Product]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -771,9 +915,9 @@ async def api_get_order( @nostrmarket_ext.get("/api/v1/order") async def api_get_orders( - paid: bool | None = None, - shipped: bool | None = None, - pubkey: str | None = None, + paid: Optional[bool] = None, + shipped: Optional[bool] = None, + pubkey: Optional[str] = None, wallet: WalletTypeInfo = Depends(require_invoice_key), ): try: @@ -817,27 +961,11 @@ async def api_update_order_status( ensure_ascii=False, ) - dm_event = merchant.build_dm_event(dm_content, order.public_key) - - dm = PartialDirectMessage( - event_id=dm_event.id, - event_created_at=dm_event.created_at, - message=dm_content, - public_key=order.public_key, - type=DirectMessageType.ORDER_PAID_OR_SHIPPED.value, - ) - await create_direct_message(merchant.id, dm) - - await nostr_client.publish_nostr_event(dm_event) - await websocket_updater( - merchant.id, - json.dumps( - { - "type": f"dm:{dm.type}", - "customerPubkey": order.public_key, - "dm": dm.dict(), - } - ), + await send_dm( + merchant, + order.public_key, + DirectMessageType.ORDER_PAID_OR_SHIPPED.value, + dm_content, ) return order @@ -859,7 +987,7 @@ async def api_update_order_status( async def api_restore_order( event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), -) -> Order | None: +) -> Optional[Order]: try: merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" @@ -986,7 +1114,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" @@ -1015,14 +1143,13 @@ async def api_create_message( merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" - dm_event = merchant.build_dm_event(data.message, data.public_key) - data.event_id = dm_event.id - data.event_created_at = dm_event.created_at - - dm = await create_direct_message(merchant.id, data) - await nostr_client.publish_nostr_event(dm_event) - - return dm + dm_reply = await send_dm( + merchant, + data.public_key, + data.type, + data.message, + ) + return dm_reply except AssertionError as ex: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -1042,7 +1169,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"