From 631675d2a0ac95f1262b94c19d1846666e108887 Mon Sep 17 00:00:00 2001 From: padreug Date: Wed, 3 Sep 2025 10:54:07 +0200 Subject: [PATCH 1/8] add DEBUG logs --- nostr/nostr_client.py | 13 ++++++++----- services.py | 36 +++++++++++++++++++++++++++++++----- tasks.py | 6 +++++- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index a611980..8a2da32 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -91,9 +91,11 @@ 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}" + logger.info( + f"[NOSTRMARKET DEBUG] Subscribing to events for: {len(public_keys)} keys. New subscription id: {self.subscription_id}" ) + logger.info(f"[NOSTRMARKET DEBUG] Subscription filters: {merchant_filters}") + logger.info(f"[NOSTRMARKET DEBUG] Public keys: {public_keys}") async def merchant_temp_subscription(self, pk, duration=10): dm_filters = self._filters_for_direct_messages([pk], 0) @@ -175,16 +177,17 @@ class NostrClient: def _ws_handlers(self): def on_open(_): - logger.info("Connected to 'nostrclient' websocket") + logger.info("[NOSTRMARKET DEBUG] Connected to 'nostrclient' websocket") def on_message(_, message): + logger.info(f"[NOSTRMARKET DEBUG] Received websocket message: {message[:200]}...") self.recieve_event_queue.put_nowait(message) def on_error(_, error): - logger.warning(error) + logger.warning(f"[NOSTRMARKET DEBUG] Websocket error: {error}") def on_close(x, status_code, message): - logger.warning(f"Websocket closed: {x}: '{status_code}' '{message}'") + logger.warning(f"[NOSTRMARKET DEBUG] Websocket closed: {x}: '{status_code}' '{message}'") # force re-subscribe self.recieve_event_queue.put_nowait(ValueError("Websocket close.")) diff --git a/services.py b/services.py index b978e39..ff72c16 100644 --- a/services.py +++ b/services.py @@ -312,24 +312,32 @@ async def compute_products_new_quantity( async def process_nostr_message(msg: str): + logger.info(f"[NOSTRMARKET DEBUG] Received nostr message: {msg[:200]}...") try: type_, *rest = json.loads(msg) if type_.upper() == "EVENT": _, event = rest event = NostrEvent(**event) + logger.info(f"[NOSTRMARKET DEBUG] Processing event - kind: {event.kind}, id: {event.id}, pubkey: {event.pubkey}") if event.kind == 0: + logger.info(f"[NOSTRMARKET DEBUG] Handling customer profile update - event: {event.id}") await _handle_customer_profile_update(event) elif event.kind == 4: + logger.info(f"[NOSTRMARKET DEBUG] Handling NIP04 message - event: {event.id}, from: {event.pubkey}, content_length: {len(event.content)}") await _handle_nip04_message(event) elif event.kind == 30017: + logger.info(f"[NOSTRMARKET DEBUG] Handling stall event - event: {event.id}") await _handle_stall(event) elif event.kind == 30018: + logger.info(f"[NOSTRMARKET DEBUG] Handling product event - event: {event.id}") await _handle_product(event) + else: + logger.info(f"[NOSTRMARKET DEBUG] Unhandled event kind: {event.kind} - event: {event.id}") return except Exception as ex: - logger.debug(ex) + logger.error(f"[NOSTRMARKET DEBUG] Error processing nostr message: {ex}") async def create_or_update_order_from_dm( @@ -411,40 +419,53 @@ async def extract_customer_order_from_dm( async def _handle_nip04_message(event: NostrEvent): + logger.info(f"[NOSTRMARKET DEBUG] _handle_nip04_message - event: {event.id}, from: {event.pubkey}, tags: {event.tags}") merchant_public_key = event.pubkey merchant = await get_merchant_by_pubkey(merchant_public_key) + logger.info(f"[NOSTRMARKET DEBUG] Looking for merchant by pubkey {merchant_public_key}, found: {merchant is not None}") if not merchant: p_tags = event.tag_values("p") + logger.info(f"[NOSTRMARKET DEBUG] No merchant found for sender, checking p_tags: {p_tags}") if len(p_tags) and p_tags[0]: merchant_public_key = p_tags[0] merchant = await get_merchant_by_pubkey(merchant_public_key) + logger.info(f"[NOSTRMARKET DEBUG] Looking for merchant by p_tag {merchant_public_key}, found: {merchant is not None}") assert merchant, f"Merchant not found for public key '{merchant_public_key}'" if event.pubkey == merchant_public_key: + logger.info(f"[NOSTRMARKET DEBUG] Processing outgoing message from merchant {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.info(f"[NOSTRMARKET DEBUG] Decrypted outgoing message: {clear_text_msg[:100]}...") await _handle_outgoing_dms(event, merchant, clear_text_msg) elif event.has_tag_value("p", merchant_public_key): + logger.info(f"[NOSTRMARKET DEBUG] Processing incoming message to merchant {merchant_public_key} from {event.pubkey}") clear_text_msg = merchant.decrypt_message(event.content, event.pubkey) + logger.info(f"[NOSTRMARKET DEBUG] Decrypted incoming message: {clear_text_msg[:100]}...") await _handle_incoming_dms(event, merchant, clear_text_msg) else: - logger.warning(f"Bad NIP04 event: '{event.id}'") + logger.warning(f"[NOSTRMARKET DEBUG] Bad NIP04 event: '{event.id}' - pubkey: {event.pubkey}, merchant: {merchant_public_key}") async def _handle_incoming_dms( event: NostrEvent, merchant: Merchant, clear_text_msg: str ): + logger.info(f"[NOSTRMARKET DEBUG] _handle_incoming_dms - merchant: {merchant.id}, customer: {event.pubkey}") customer = await get_customer(merchant.id, event.pubkey) if not customer: + logger.info(f"[NOSTRMARKET DEBUG] Creating new customer for pubkey: {event.pubkey}") await _handle_new_customer(event, merchant) else: + logger.info(f"[NOSTRMARKET DEBUG] Existing customer found, incrementing unread messages") await increment_customer_unread_messages(merchant.id, event.pubkey) + logger.info(f"[NOSTRMARKET DEBUG] Parsing message: {clear_text_msg}") dm_type, json_data = PartialDirectMessage.parse_message(clear_text_msg) + logger.info(f"[NOSTRMARKET DEBUG] Parsed message - type: {dm_type}, has_json_data: {json_data is not None}") new_dm = await _persist_dm( merchant.id, dm_type.value, @@ -482,17 +503,22 @@ 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]]: + logger.info(f"[NOSTRMARKET DEBUG] _handle_incoming_structured_dm - merchant: {merchant.id}, dm_type: {dm.type}, merchant_active: {merchant.config.active}") + logger.info(f"[NOSTRMARKET DEBUG] JSON data: {json_data}") try: if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active: + logger.info(f"[NOSTRMARKET DEBUG] Processing CUSTOMER_ORDER for merchant {merchant.id}") json_resp = await _handle_new_order( merchant.id, merchant.public_key, dm, json_data ) - + logger.info(f"[NOSTRMARKET DEBUG] New order processed, response: {json_resp[:100] if json_resp else None}...") return DirectMessageType.PAYMENT_REQUEST, json_resp + else: + logger.info(f"[NOSTRMARKET DEBUG] 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 DEBUG] Error in _handle_incoming_structured_dm: {ex}") return DirectMessageType.PLAIN_TEXT, None diff --git a/tasks.py b/tasks.py index 013a281..774951f 100644 --- a/tasks.py +++ b/tasks.py @@ -35,13 +35,17 @@ async def on_invoice_paid(payment: Payment) -> None: async def wait_for_nostr_events(nostr_client: NostrClient): + logger.info("[NOSTRMARKET DEBUG] Starting wait_for_nostr_events task") while True: try: + logger.info("[NOSTRMARKET DEBUG] Subscribing to all merchants...") await subscribe_to_all_merchants() while True: + logger.debug("[NOSTRMARKET DEBUG] Waiting for nostr event...") message = await nostr_client.get_event() + logger.info(f"[NOSTRMARKET DEBUG] Received event from nostr_client: {message[:100]}...") await process_nostr_message(message) except Exception as e: - logger.warning(f"Subcription failed. Will retry in one minute: {e}") + logger.warning(f"[NOSTRMARKET DEBUG] Subscription failed. Will retry in 10 seconds: {e}") await asyncio.sleep(10) From 7c7d6c7953f096726032bc65b01a5c4500090ef0 Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 4 Nov 2025 00:47:43 +0100 Subject: [PATCH 2/8] interactive rebase commit, clean logs --- services.py | 74 +++++++++++++++++++++++++++++------------------------ 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/services.py b/services.py index ff72c16..d32bf35 100644 --- a/services.py +++ b/services.py @@ -63,7 +63,8 @@ async def create_new_order( 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,11 +74,12 @@ 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( @@ -106,9 +108,10 @@ async def build_order_with_payment( if not success: 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", @@ -312,32 +315,26 @@ async def compute_products_new_quantity( async def process_nostr_message(msg: str): - logger.info(f"[NOSTRMARKET DEBUG] Received nostr message: {msg[:200]}...") try: type_, *rest = json.loads(msg) if type_.upper() == "EVENT": _, event = rest event = NostrEvent(**event) - logger.info(f"[NOSTRMARKET DEBUG] Processing event - kind: {event.kind}, id: {event.id}, pubkey: {event.pubkey}") if event.kind == 0: - logger.info(f"[NOSTRMARKET DEBUG] Handling customer profile update - event: {event.id}") await _handle_customer_profile_update(event) elif event.kind == 4: - logger.info(f"[NOSTRMARKET DEBUG] Handling NIP04 message - event: {event.id}, from: {event.pubkey}, content_length: {len(event.content)}") await _handle_nip04_message(event) elif event.kind == 30017: - logger.info(f"[NOSTRMARKET DEBUG] Handling stall event - event: {event.id}") await _handle_stall(event) elif event.kind == 30018: - logger.info(f"[NOSTRMARKET DEBUG] Handling product event - event: {event.id}") await _handle_product(event) else: - logger.info(f"[NOSTRMARKET DEBUG] Unhandled event kind: {event.kind} - event: {event.id}") + logger.info(f"[NOSTRMARKET] Unhandled event kind: {event.kind} - event: {event.id}") return except Exception as ex: - logger.error(f"[NOSTRMARKET DEBUG] Error processing nostr message: {ex}") + logger.error(f"[NOSTRMARKET] Error processing nostr message: {ex}") async def create_or_update_order_from_dm( @@ -419,53 +416,40 @@ async def extract_customer_order_from_dm( async def _handle_nip04_message(event: NostrEvent): - logger.info(f"[NOSTRMARKET DEBUG] _handle_nip04_message - event: {event.id}, from: {event.pubkey}, tags: {event.tags}") merchant_public_key = event.pubkey merchant = await get_merchant_by_pubkey(merchant_public_key) - logger.info(f"[NOSTRMARKET DEBUG] Looking for merchant by pubkey {merchant_public_key}, found: {merchant is not None}") if not merchant: p_tags = event.tag_values("p") - logger.info(f"[NOSTRMARKET DEBUG] No merchant found for sender, checking p_tags: {p_tags}") if len(p_tags) and p_tags[0]: merchant_public_key = p_tags[0] merchant = await get_merchant_by_pubkey(merchant_public_key) - logger.info(f"[NOSTRMARKET DEBUG] Looking for merchant by p_tag {merchant_public_key}, found: {merchant is not None}") assert merchant, f"Merchant not found for public key '{merchant_public_key}'" if event.pubkey == merchant_public_key: - logger.info(f"[NOSTRMARKET DEBUG] Processing outgoing message from merchant {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.info(f"[NOSTRMARKET DEBUG] Decrypted outgoing message: {clear_text_msg[:100]}...") await _handle_outgoing_dms(event, merchant, clear_text_msg) elif event.has_tag_value("p", merchant_public_key): - logger.info(f"[NOSTRMARKET DEBUG] Processing incoming message to merchant {merchant_public_key} from {event.pubkey}") clear_text_msg = merchant.decrypt_message(event.content, event.pubkey) - logger.info(f"[NOSTRMARKET DEBUG] Decrypted incoming message: {clear_text_msg[:100]}...") await _handle_incoming_dms(event, merchant, clear_text_msg) else: - logger.warning(f"[NOSTRMARKET DEBUG] Bad NIP04 event: '{event.id}' - pubkey: {event.pubkey}, merchant: {merchant_public_key}") + logger.warning(f"[NOSTRMARKET] Bad NIP04 event: '{event.id}' - pubkey: {event.pubkey}, merchant: {merchant_public_key}") async def _handle_incoming_dms( event: NostrEvent, merchant: Merchant, clear_text_msg: str ): - logger.info(f"[NOSTRMARKET DEBUG] _handle_incoming_dms - merchant: {merchant.id}, customer: {event.pubkey}") customer = await get_customer(merchant.id, event.pubkey) if not customer: - logger.info(f"[NOSTRMARKET DEBUG] Creating new customer for pubkey: {event.pubkey}") await _handle_new_customer(event, merchant) else: - logger.info(f"[NOSTRMARKET DEBUG] Existing customer found, incrementing unread messages") await increment_customer_unread_messages(merchant.id, event.pubkey) - logger.info(f"[NOSTRMARKET DEBUG] Parsing message: {clear_text_msg}") dm_type, json_data = PartialDirectMessage.parse_message(clear_text_msg) - logger.info(f"[NOSTRMARKET DEBUG] Parsed message - type: {dm_type}, has_json_data: {json_data is not None}") new_dm = await _persist_dm( merchant.id, dm_type.value, @@ -504,21 +488,17 @@ async def _handle_outgoing_dms( async def _handle_incoming_structured_dm( merchant: Merchant, dm: DirectMessage, json_data: dict ) -> Tuple[DirectMessageType, Optional[str]]: - logger.info(f"[NOSTRMARKET DEBUG] _handle_incoming_structured_dm - merchant: {merchant.id}, dm_type: {dm.type}, merchant_active: {merchant.config.active}") - logger.info(f"[NOSTRMARKET DEBUG] JSON data: {json_data}") try: if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active: - logger.info(f"[NOSTRMARKET DEBUG] Processing CUSTOMER_ORDER for merchant {merchant.id}") json_resp = await _handle_new_order( merchant.id, merchant.public_key, dm, json_data ) - logger.info(f"[NOSTRMARKET DEBUG] New order processed, response: {json_resp[:100] if json_resp else None}...") return DirectMessageType.PAYMENT_REQUEST, json_resp else: - logger.info(f"[NOSTRMARKET DEBUG] Skipping order processing - type: {dm.type}, expected: {DirectMessageType.CUSTOMER_ORDER.value}, merchant_active: {merchant.config.active}") + logger.info(f"[NOSTRMARKET] Skipping order processing - type: {dm.type}, expected: {DirectMessageType.CUSTOMER_ORDER.value}, merchant_active: {merchant.config.active}") except Exception as ex: - logger.error(f"[NOSTRMARKET DEBUG] Error in _handle_incoming_structured_dm: {ex}") + logger.error(f"[NOSTRMARKET] Error in _handle_incoming_structured_dm: {ex}") return DirectMessageType.PLAIN_TEXT, None @@ -600,8 +580,29 @@ async def _handle_new_order( 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, @@ -609,12 +610,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( From 7245123e498742ac3c0b75ccc9f453427a45e003 Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 7 Sep 2025 03:25:19 +0200 Subject: [PATCH 3/8] Improve merchant creation with automatic keypair generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhance the merchant creation process by automatically generating Nostr keypairs for users who don't have them, and streamline the API interface. Changes: - Add CreateMerchantRequest model to simplify merchant creation API - Auto-generate Nostr keypairs for users without existing keys - Update merchant creation endpoint to use user account keypairs - Improve error handling and validation in merchant creation flow - Clean up frontend JavaScript for merchant creation This ensures all merchants have proper Nostr keypairs for marketplace functionality without requiring manual key management from users. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- models.py | 4 ++++ static/js/index.js | 52 ++++++++++------------------------------------ views_api.py | 46 +++++++++++++++++++++++++++++++++++----- 3 files changed, 56 insertions(+), 46 deletions(-) diff --git a/models.py b/models.py index 58842d5..d56dbcf 100644 --- a/models.py +++ b/models.py @@ -49,6 +49,10 @@ class MerchantConfig(MerchantProfile): restore_in_progress: bool | None = False +class CreateMerchantRequest(BaseModel): + config: MerchantConfig = MerchantConfig() + + class PartialMerchant(BaseModel): private_key: str public_key: str diff --git a/static/js/index.js b/static/js/index.js index f14bf1d..b10220c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -49,46 +49,19 @@ window.app = Vue.createApp({ } }, methods: { - generateKeys: function () { - const privateKey = nostr.generatePrivateKey() - const publicKey = nostr.getPublicKey(privateKey) - this.generateKeyDialog.privateKey = privateKey - this.generateKeyDialog.nsec = nostr.nip19.nsecEncode(privateKey) - this.generateKeyDialog.npub = nostr.nip19.npubEncode(publicKey) - this.generateKeyDialog.showNsec = false - this.generateKeyDialog.show = true - }, - confirmGenerateKey: async function () { - this.generateKeyDialog.show = false - await this.createMerchant(this.generateKeyDialog.privateKey) + generateKeys: async function () { + // No longer need to generate keys here - the backend will use user's existing keypairs + await this.createMerchant() }, importKeys: async function () { this.importKeyDialog.show = false - let privateKey = this.importKeyDialog.data.privateKey - if (!privateKey) { - return - } - try { - if (privateKey.toLowerCase().startsWith('nsec')) { - privateKey = nostr.nip19.decode(privateKey).data - } - // Check if this key is already in use - const publicKey = nostr.getPublicKey(privateKey) - if (this.merchant?.public_key === publicKey) { - this.$q.notify({ - type: 'warning', - message: 'This key is already your current profile' - }) - return - } - } catch (error) { - this.$q.notify({ - type: 'negative', - message: `${error}` - }) - return - } - await this.createMerchant(privateKey) + // Import keys functionality removed since we use user's native keypairs + // Show a message that this is no longer needed + this.$q.notify({ + type: 'info', + message: 'Merchants now use your account Nostr keys automatically. Key import is no longer needed.', + timeout: 3000 + }) }, showImportKeysDialog: async function () { this.importKeyDialog.show = true @@ -143,12 +116,9 @@ window.app = Vue.createApp({ this.showKeys = false this.stallCount = 0 }, - createMerchant: async function (privateKey) { + createMerchant: async function () { try { - const pubkey = nostr.getPublicKey(privateKey) const payload = { - private_key: privateKey, - public_key: pubkey, config: {} } const {data} = await LNbits.api.request( diff --git a/views_api.py b/views_api.py index 1e9f5c5..8d41963 100644 --- a/views_api.py +++ b/views_api.py @@ -4,12 +4,14 @@ from http import HTTPStatus from fastapi import Depends from fastapi.exceptions import HTTPException from lnbits.core.models import WalletTypeInfo +from lnbits.core.crud import get_account, update_account from lnbits.core.services import websocket_updater from lnbits.decorators import ( require_admin_key, require_invoice_key, ) from lnbits.utils.exchange_rates import currencies +from lnbits.utils.nostr import generate_keypair from loguru import logger from . import nostr_client, nostrmarket_ext @@ -58,6 +60,7 @@ from .crud import ( ) from .helpers import normalize_public_key from .models import ( + CreateMerchantRequest, Customer, DirectMessage, DirectMessageType, @@ -90,15 +93,48 @@ from .services import ( @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" + # Check if merchant already exists for this user + 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) + # Get user's account to access their Nostr keypairs + account = await get_account(wallet.wallet.user) + if not account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="User account not found", + ) + + # Check if user has Nostr keypairs, generate them if not + if not account.pubkey or not account.prvkey: + # Generate new keypair for user + private_key, public_key = generate_keypair() + + # Update user account with new keypairs + account.pubkey = public_key + account.prvkey = private_key + await update_account(account) + else: + public_key = account.pubkey + private_key = account.prvkey + + # Check if another merchant is already using this public key + existing_merchant = await get_merchant_by_pubkey(public_key) + assert existing_merchant is None, "A merchant already uses this public key" + + # Create PartialMerchant with user's keypairs + partial_merchant = PartialMerchant( + private_key=private_key, + public_key=public_key, + config=data.config + ) + + merchant = await create_merchant(wallet.wallet.user, partial_merchant) await create_zone( merchant.id, @@ -113,7 +149,7 @@ async def api_create_merchant( await resubscribe_to_all_merchants() - await nostr_client.merchant_temp_subscription(data.public_key) + await nostr_client.merchant_temp_subscription(public_key) return merchant except AssertionError as ex: From 0fec454006a1e49a776c5b1818b8c7395268a66c Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 8 Sep 2025 21:50:54 +0200 Subject: [PATCH 4/8] TEMP: set merchant active to True by default --- models.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/models.py b/models.py index d56dbcf..71f977b 100644 --- a/models.py +++ b/models.py @@ -43,10 +43,11 @@ class MerchantProfile(BaseModel): 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 class CreateMerchantRequest(BaseModel): From 61c2db8e803954df10a9c31032703d4ee087a82a Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 3 Nov 2025 23:58:30 +0100 Subject: [PATCH 5/8] Enhances websocket connection robustness Improves websocket connection reliability by predefining the websocket URL and handling potential queueing errors. This change also updates the websocket close message for clarity. --- nostr/nostr_client.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index 8a2da32..0e38029 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -31,9 +31,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, @@ -181,13 +183,16 @@ class NostrClient: def on_message(_, message): logger.info(f"[NOSTRMARKET DEBUG] Received websocket message: {message[:200]}...") - self.recieve_event_queue.put_nowait(message) + try: + self.recieve_event_queue.put_nowait(message) + except Exception as e: + logger.error(f"[NOSTRMARKET DEBUG] ❌ Failed to queue message: {e}") def on_error(_, error): - logger.warning(f"[NOSTRMARKET DEBUG] Websocket error: {error}") + logger.warning(f"[NOSTRMARKET DEBUG] ❌ Websocket error: {error}") def on_close(x, status_code, message): - logger.warning(f"[NOSTRMARKET DEBUG] Websocket closed: {x}: '{status_code}' '{message}'") + logger.warning(f"[NOSTRMARKET DEBUG] 🔌 Websocket closed: {x}: '{status_code}' '{message}'") # force re-subscribe self.recieve_event_queue.put_nowait(ValueError("Websocket close.")) From 1fd01f8c06917a5aa5dda5a14aa82f0bbbece7b7 Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 4 Nov 2025 00:38:29 +0100 Subject: [PATCH 6/8] Adds order discovery analysis document Adds a document analyzing the order discovery mechanism in Nostrmarket. The document identifies the reasons merchants need to manually refresh to see new orders, instead of receiving them automatically. It analyzes timing window issues, connection stability, subscription state management, and event processing delays. It proposes solutions such as enhanced persistent subscriptions, periodic auto-refresh, WebSocket health monitoring, and event gap detection. --- misc-docs/ORDER-DISCOVERY-ANALYSIS.md | 320 ++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 misc-docs/ORDER-DISCOVERY-ANALYSIS.md 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_ From e0fdada15c087652d275f2709564fa94abd9f3d6 Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 4 Nov 2025 00:52:36 +0100 Subject: [PATCH 7/8] Improves Nostr message handling and error logging Enhances the processing of Nostr messages by adding more robust error handling and logging, providing better insights into potential issues. Specifically: - Improves the checks on the websocket connection to log errors and debug information. - Implements more comprehensive error logging for failed product quantity checks. - Enhances logging and validation of EVENT messages to prevent potential errors. - Implements a more robust merchant lookup logic to avoid double processing of events. - Implements a more lenient time window for direct message subscriptions. --- nostr/nostr_client.py | 18 +++++------- services.py | 66 +++++++++++++++++++++++++++---------------- 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index 0e38029..967bc1b 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -67,6 +67,7 @@ class NostrClient: async def get_event(self): value = await self.recieve_event_queue.get() if isinstance(value, ValueError): + logger.error(f"[NOSTRMARKET] ❌ Queue returned error: {value}") raise value return value @@ -93,12 +94,6 @@ class NostrClient: self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32] await self.send_req_queue.put(["REQ", self.subscription_id] + merchant_filters) - logger.info( - f"[NOSTRMARKET DEBUG] Subscribing to events for: {len(public_keys)} keys. New subscription id: {self.subscription_id}" - ) - logger.info(f"[NOSTRMARKET DEBUG] Subscription filters: {merchant_filters}") - logger.info(f"[NOSTRMARKET DEBUG] Public keys: {public_keys}") - 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) @@ -179,20 +174,21 @@ class NostrClient: def _ws_handlers(self): def on_open(_): - logger.info("[NOSTRMARKET DEBUG] Connected to 'nostrclient' websocket") + logger.debug("[NOSTRMARKET DEBUG] ✅ Connected to 'nostrclient' websocket successfully") def on_message(_, message): - logger.info(f"[NOSTRMARKET DEBUG] Received websocket message: {message[:200]}...") + 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 DEBUG] ❌ Failed to queue message: {e}") + logger.error(f"[NOSTRMARKET] ❌ Failed to queue message: {e}") def on_error(_, error): - logger.warning(f"[NOSTRMARKET DEBUG] ❌ Websocket error: {error}") + logger.warning(f"[NOSTRMARKET] ❌ Websocket error: {error}") def on_close(x, status_code, message): - logger.warning(f"[NOSTRMARKET DEBUG] 🔌 Websocket closed: {x}: '{status_code}' '{message}'") + logger.warning(f"[NOSTRMARKET] 🔌 Websocket closed: {x}: '{status_code}' '{message}'") # force re-subscribe self.recieve_event_queue.put_nowait(ValueError("Websocket close.")) diff --git a/services.py b/services.py index d32bf35..4b14299 100644 --- a/services.py +++ b/services.py @@ -85,10 +85,13 @@ async def create_new_order( 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}'" @@ -96,6 +99,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) @@ -106,6 +110,7 @@ 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) @@ -316,9 +321,14 @@ 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) if event.kind == 0: @@ -330,11 +340,14 @@ async def process_nostr_message(msg: str): elif event.kind == 30018: await _handle_product(event) else: - logger.info(f"[NOSTRMARKET] Unhandled event kind: {event.kind} - event: {event.id}") + logger.info(f"[NOSTRMARKET] ❓ Unhandled event kind: {event.kind} - event: {event.id}") return + else: + logger.info(f"[NOSTRMARKET] 🔄 Non-EVENT message type: {type_}") except Exception as ex: - logger.error(f"[NOSTRMARKET] Error processing nostr message: {ex}") + logger.error(f"[NOSTRMARKET] ❌ Error processing nostr message: {ex}") + logger.error(f"[NOSTRMARKET] 📄 Raw message that failed: {msg}") async def create_or_update_order_from_dm( @@ -416,28 +429,29 @@ async def extract_customer_order_from_dm( async def _handle_nip04_message(event: NostrEvent): - merchant_public_key = event.pubkey - merchant = await get_merchant_by_pubkey(merchant_public_key) - - if not merchant: - p_tags = event.tag_values("p") - if len(p_tags) and p_tags[0]: - merchant_public_key = p_tags[0] - merchant = await get_merchant_by_pubkey(merchant_public_key) - - 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( + + p_tags = event.tag_values("p") + + # PRIORITY 1: Check if any recipient (p_tag) is a merchant → incoming message TO merchant + for p_tag in p_tags: + if p_tag: + potential_merchant = await get_merchant_by_pubkey(p_tag) + if potential_merchant: + clear_text_msg = potential_merchant.decrypt_message(event.content, event.pubkey) + await _handle_incoming_dms(event, potential_merchant, clear_text_msg) + return # IMPORTANT: Return immediately to prevent double processing + + # PRIORITY 2: If no recipient merchant found, check if sender is a merchant → outgoing message FROM merchant + sender_merchant = await get_merchant_by_pubkey(event.pubkey) + if sender_merchant: + assert len(event.tag_values("p")) != 0, "Outgoing message has no 'p' tag" + clear_text_msg = sender_merchant.decrypt_message( event.content, event.tag_values("p")[0] ) - 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"[NOSTRMARKET] Bad NIP04 event: '{event.id}' - pubkey: {event.pubkey}, merchant: {merchant_public_key}") + await _handle_outgoing_dms(event, sender_merchant, clear_text_msg) + return # IMPORTANT: Return immediately + + # No merchant found in either direction async def _handle_incoming_dms( @@ -579,6 +593,7 @@ 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: @@ -653,8 +668,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 ) From f85cbaa65e45e259a5f5b6f354b406e80fc4fc91 Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 4 Nov 2025 00:59:55 +0100 Subject: [PATCH 8/8] Refactors type hints for clarity Updates type hints in `crud.py`, `helpers.py`, and `models.py` for improved readability and maintainability. Replaces `Merchant | None` with `Optional[Merchant]` and `list[X]` with `List[X]` for consistency with standard Python typing practices. --- crud.py | 69 +++++++++++++++-------------- helpers.py | 3 +- models.py | 120 +++++++++++++++++++++++++-------------------------- services.py | 16 ++++--- views_api.py | 31 ++++++------- 5 files changed, 123 insertions(+), 116 deletions(-) diff --git a/crud.py b/crud.py index 3bec1f2..adc0836 100644 --- a/crud.py +++ b/crud.py @@ -1,4 +1,5 @@ import json +from typing import List, Optional, Tuple from lnbits.helpers import urlsafe_short_hash @@ -43,7 +44,7 @@ async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant: async def update_merchant( user_id: str, merchant_id: str, config: MerchantConfig -) -> Merchant | None: +) -> Optional[Merchant]: await db.execute( f""" UPDATE nostrmarket.merchants SET meta = :meta, time = {db.timestamp_now} @@ -54,7 +55,7 @@ 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 touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: await db.execute( f""" UPDATE nostrmarket.merchants SET time = {db.timestamp_now} @@ -65,7 +66,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 +78,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 +87,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 +95,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 +138,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 +157,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 +168,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 +235,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 +249,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 +274,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 +398,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 +415,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 +431,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 +445,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 +467,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 +574,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 +588,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 +602,7 @@ async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None return Order.from_row(row) if row else None -async def get_orders(merchant_id: str, **kwargs) -> list[Order]: +async def get_orders(merchant_id: str, **kwargs) -> List[Order]: q = " AND ".join( [ f"{field[0]} = :{field[0]}" @@ -626,7 +629,7 @@ async def get_orders(merchant_id: str, **kwargs) -> list[Order]: async def get_orders_for_stall( merchant_id: str, stall_id: str, **kwargs -) -> list[Order]: +) -> List[Order]: q = " AND ".join( [ f"{field[0]} = :{field[0]}" @@ -652,7 +655,7 @@ async def get_orders_for_stall( return [Order.from_row(row) for row in rows] -async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | None: +async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Order]: q = ", ".join( [ f"{field[0]} = :{field[0]}" @@ -676,7 +679,7 @@ async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | Non return await get_order(merchant_id, order_id) -async def update_order_paid_status(order_id: str, paid: bool) -> Order | None: +async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]: await db.execute( "UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id", {"paid": paid, "id": order_id}, @@ -690,7 +693,7 @@ async def update_order_paid_status(order_id: str, paid: bool) -> Order | None: async def update_order_shipped_status( merchant_id: str, order_id: str, shipped: bool -) -> Order | None: +) -> Optional[Order]: await db.execute( """ UPDATE nostrmarket.orders @@ -754,7 +757,7 @@ async def create_direct_message( return msg -async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | None: +async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMessage]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.direct_messages @@ -770,7 +773,7 @@ async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | No async def get_direct_message_by_event_id( merchant_id: str, event_id: str -) -> DirectMessage | None: +) -> Optional[DirectMessage]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.direct_messages @@ -784,7 +787,7 @@ async def get_direct_message_by_event_id( return DirectMessage.from_row(row) if row else None -async def get_direct_messages(merchant_id: str, public_key: str) -> list[DirectMessage]: +async def get_direct_messages(merchant_id: str, public_key: str) -> List[DirectMessage]: rows: list[dict] = await db.fetchall( """ SELECT * FROM nostrmarket.direct_messages @@ -796,7 +799,7 @@ async def get_direct_messages(merchant_id: str, public_key: str) -> list[DirectM return [DirectMessage.from_row(row) for row in rows] -async def get_orders_from_direct_messages(merchant_id: str) -> list[DirectMessage]: +async def get_orders_from_direct_messages(merchant_id: str) -> List[DirectMessage]: rows: list[dict] = await db.fetchall( """ SELECT * FROM nostrmarket.direct_messages @@ -857,7 +860,7 @@ async def create_customer(merchant_id: str, data: Customer) -> Customer: return customer -async def get_customer(merchant_id: str, public_key: str) -> Customer | None: +async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]: row: dict = await db.fetchone( """ SELECT * FROM nostrmarket.customers @@ -871,7 +874,7 @@ async def get_customer(merchant_id: str, public_key: str) -> Customer | None: return Customer.from_row(row) if row else None -async def get_customers(merchant_id: str) -> list[Customer]: +async def get_customers(merchant_id: str) -> List[Customer]: rows: list[dict] = await db.fetchall( "SELECT * FROM nostrmarket.customers WHERE merchant_id = :merchant_id", {"merchant_id": merchant_id}, @@ -879,7 +882,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/helpers.py b/helpers.py index dcc0f06..dd26116 100644 --- a/helpers.py +++ b/helpers.py @@ -1,5 +1,6 @@ import base64 import secrets +from typing import Optional import coincurve from bech32 import bech32_decode, convertbits @@ -36,7 +37,7 @@ def decrypt_message(encoded_message: str, encryption_key) -> str: return unpadded_data.decode() -def encrypt_message(message: str, encryption_key, iv: bytes | None = None) -> str: +def encrypt_message(message: str, encryption_key, iv: Optional[bytes] = None) -> str: padder = padding.PKCS7(128).padder() padded_data = padder.update(message.encode()) + padder.finalize() diff --git a/models.py b/models.py index 71f977b..b12b775 100644 --- a/models.py +++ b/models.py @@ -2,7 +2,7 @@ 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 @@ -32,14 +32,14 @@ class Nostrable: class MerchantProfile(BaseModel): - name: str | None = None - display_name: str | None = None - about: str | None = None - picture: str | None = None - banner: str | None = None - website: str | None = None - nip05: str | None = None - lud16: str | None = None + name: Optional[str] = None + display_name: Optional[str] = None + about: Optional[str] = None + picture: Optional[str] = None + banner: Optional[str] = None + website: Optional[str] = None + nip05: Optional[str] = None + lud16: Optional[str] = None class MerchantConfig(MerchantProfile): @@ -62,7 +62,7 @@ class PartialMerchant(BaseModel): class Merchant(PartialMerchant, Nostrable): id: str - time: int | None = 0 + time: Optional[int] = 0 def sign_hash(self, hash_: bytes) -> str: return sign_message_hash(self.private_key, hash_) @@ -144,11 +144,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": @@ -161,22 +161,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: @@ -234,19 +234,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 @@ -254,8 +254,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 = { @@ -312,7 +312,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": @@ -329,21 +329,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 @@ -359,19 +359,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 @@ -392,8 +392,8 @@ class PartialOrder(BaseModel): ) async def costs_in_sats( - self, products: list[Product], shipping_id: str, stall_shipping_cost: float - ) -> tuple[float, float]: + self, products: List[Product], shipping_id: str, stall_shipping_cost: float + ) -> Tuple[float, float]: product_prices = {} for p in products: product_shipping_cost = next( @@ -422,7 +422,7 @@ class PartialOrder(BaseModel): return product_cost, stall_shipping_cost def receipt( - self, products: list[Product], shipping_id: str, stall_shipping_cost: float + self, products: List[Product], shipping_id: str, stall_shipping_cost: float ) -> str: if len(products) == 0: return "[No Products]" @@ -471,7 +471,7 @@ class Order(PartialOrder): total: float paid: bool = False shipped: bool = False - time: int | None = None + time: Optional[int] = None extra: OrderExtra @classmethod @@ -485,14 +485,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): @@ -502,8 +502,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 ####################################### @@ -519,16 +519,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: @@ -551,15 +551,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/services.py b/services.py index 4b14299..3057292 100644 --- a/services.py +++ b/services.py @@ -1,7 +1,8 @@ import asyncio import json +from typing import List, Optional, Tuple -from bolt11 import decode +from lnbits.bolt11 import decode from lnbits.core.crud import get_wallet from lnbits.core.services import create_invoice, websocket_updater from loguru import logger @@ -59,7 +60,7 @@ from .nostr.event import NostrEvent 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!" @@ -144,7 +145,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) @@ -228,7 +229,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 @@ -296,9 +297,9 @@ async def send_dm( async def compute_products_new_quantity( - merchant_id: str, product_ids: list[str], items: list[OrderItem] -) -> tuple[bool, list[Product], str]: - products: list[Product] = await get_products_by_ids(merchant_id, product_ids) + merchant_id: str, product_ids: List[str], items: List[OrderItem] +) -> Tuple[bool, List[Product], str]: + products: List[Product] = await get_products_by_ids(merchant_id, product_ids) for p in products: required_quantity = next( @@ -331,6 +332,7 @@ async def process_nostr_message(msg: str): return _, event = rest event = NostrEvent(**event) + if event.kind == 0: await _handle_customer_profile_update(event) elif event.kind == 4: diff --git a/views_api.py b/views_api.py index 8d41963..7bf3574 100644 --- a/views_api.py +++ b/views_api.py @@ -1,12 +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, update_account from lnbits.core.services import websocket_updater from lnbits.decorators import ( + WalletTypeInfo, require_admin_key, require_invoice_key, ) @@ -168,7 +169,7 @@ async def api_create_merchant( @nostrmarket_ext.get("/api/v1/merchant") async def api_get_merchant( wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> Merchant | None: +) -> Optional[Merchant]: try: merchant = await get_merchant_for_user(wallet.wallet.user) @@ -365,7 +366,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" @@ -565,7 +566,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: @@ -589,7 +590,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: @@ -613,9 +614,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: @@ -749,7 +750,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" @@ -834,9 +835,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: @@ -922,7 +923,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" @@ -1049,7 +1050,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" @@ -1105,7 +1106,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"