diff --git a/__init__.py b/__init__.py index cffa9fa..921c383 100644 --- a/__init__.py +++ b/__init__.py @@ -27,11 +27,7 @@ def nostrmarket_renderer(): nostr_client: NostrClient = NostrClient() -from .tasks import ( # noqa - subscription_health_monitor, - wait_for_nostr_events, - wait_for_paid_invoices, -) +from .tasks import wait_for_nostr_events, wait_for_paid_invoices # noqa from .views import * # noqa from .views_api import * # noqa @@ -69,13 +65,4 @@ def nostrmarket_start(): task3 = create_permanent_unique_task( "ext_nostrmarket_wait_for_events", _wait_for_nostr_events ) - - async def _health_monitor(): - # start after the subscription is active - await asyncio.sleep(20) - await subscription_health_monitor(nostr_client) - - task4 = create_permanent_unique_task( - "ext_nostrmarket_health_monitor", _health_monitor - ) - scheduled_tasks.extend([task1, task2, task3, task4]) + scheduled_tasks.extend([task1, task2, task3]) diff --git a/crud.py b/crud.py index 2fe8453..adc0836 100644 --- a/crud.py +++ b/crud.py @@ -23,19 +23,16 @@ from .models import ( async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant: merchant_id = urlsafe_short_hash() - # Post-aiolabs/nostrmarket#5: no `private_key` column written. The - # legacy column is dropped by `migrations_fork.m001_aio_drop_private_key` - # for fresh installs and NULL-tolerated for the brief window between - # this code change deploying and the fork-migration running. await db.execute( """ INSERT INTO nostrmarket.merchants - (user_id, id, public_key, meta) - VALUES (:user_id, :id, :public_key, :meta) + (user_id, id, private_key, public_key, meta) + VALUES (:user_id, :id, :private_key, :public_key, :meta) """, { "user_id": user_id, "id": merchant_id, + "private_key": m.private_key, "public_key": m.public_key, "meta": json.dumps(dict(m.config)), }, @@ -58,32 +55,6 @@ async def update_merchant( return await get_merchant(user_id, merchant_id) -async def update_merchant_pubkey( - user_id: str, merchant_id: str, public_key: str -) -> Optional[Merchant]: - """Re-point a merchant's identity to a new pubkey (e.g. after the - account migrated to a fresh RemoteBunkerSigner keypair). - - Post-aiolabs/nostrmarket#5: there is no `private_key` column to - update — the merchant pubkey is the only stored identity material, - and the signing nsec lives entirely in the bunker against - `account.id` (== `merchant.user_id`) on the lnbits side. - """ - await db.execute( - f""" - UPDATE nostrmarket.merchants - SET public_key = :public_key, time = {db.timestamp_now} - WHERE id = :id AND user_id = :user_id - """, - { - "public_key": public_key, - "id": merchant_id, - "user_id": user_id, - }, - ) - return await get_merchant(user_id, merchant_id) - - async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: await db.execute( f""" diff --git a/description.md b/description.md index b4fc5d2..3cfe8bc 100644 --- a/description.md +++ b/description.md @@ -5,6 +5,6 @@ Its functions include: - 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) +- Communicating via NIP-04 encrypted direct messages 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 1bc81b6..dd26116 100644 --- a/helpers.py +++ b/helpers.py @@ -1,9 +1,72 @@ -from bech32 import bech32_decode, convertbits +import base64 +import secrets +from typing import Optional -# NOTE (aiolabs/nostrmarket#5): `sign_message_hash` is gone. All merchant -# signing routes through the lnbits `NostrSigner` ABC via -# `services._resolve_merchant_signer(merchant)`. The nsec lives in the -# bunker, never in this process. +import coincurve +from bech32 import bech32_decode, convertbits +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + +def get_shared_secret(privkey: str, pubkey: str): + pk = coincurve.PublicKey(bytes.fromhex("02" + pubkey)) + sk = coincurve.PrivateKey(bytes.fromhex(privkey)) + shared_point = pk.multiply(sk.secret) + + shared_point_bytes = shared_point.format(compressed=False) + x_coord = shared_point_bytes[1:33] + return x_coord + + +def decrypt_message(encoded_message: str, encryption_key) -> str: + encoded_data = encoded_message.split("?iv=") + if len(encoded_data) == 1: + return encoded_data[0] + encoded_content, encoded_iv = encoded_data[0], encoded_data[1] + + iv = base64.b64decode(encoded_iv) + cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv)) + encrypted_content = base64.b64decode(encoded_content) + + decryptor = cipher.decryptor() + decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize() + + unpadder = padding.PKCS7(128).unpadder() + unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize() + + return unpadded_data.decode() + + +def encrypt_message(message: str, encryption_key, iv: Optional[bytes] = None) -> str: + padder = padding.PKCS7(128).padder() + padded_data = padder.update(message.encode()) + padder.finalize() + + iv = iv if iv else secrets.token_bytes(16) + cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv)) + + encryptor = cipher.encryptor() + encrypted_message = encryptor.update(padded_data) + encryptor.finalize() + + base64_message = base64.b64encode(encrypted_message).decode() + base64_iv = base64.b64encode(iv).decode() + return f"{base64_message}?iv={base64_iv}" + + +def sign_message_hash(private_key: str, hash_: bytes) -> str: + privkey = coincurve.PrivateKey(bytes.fromhex(private_key)) + sig = privkey.sign_schnorr(hash_) + return sig.hex() + + +def test_decrypt_encrypt(encoded_message: str, encryption_key): + msg = decrypt_message(encoded_message, encryption_key) + + # ecrypt using the same initialisation vector + iv = base64.b64decode(encoded_message.split("?iv=")[1]) + ecrypted_msg = encrypt_message(msg, encryption_key, iv) + assert ( + encoded_message == ecrypted_msg + ), f"expected '{encoded_message}', but got '{ecrypted_msg}'" def normalize_public_key(pubkey: str) -> str: diff --git a/migrations_fork.py b/migrations_fork.py deleted file mode 100644 index 22bf38e..0000000 --- a/migrations_fork.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -aiolabs fork-migrations for nostrmarket (companion to upstream -`migrations.py`). - -Per the aiolabs/lnbits#8 fork-migrations pattern: every aio-only -schema delta lives in this single squashed function so we never -introduce conflicts in `migrations.py` (which stays byte-identical to -upstream and rebases cleanly). - -The function is loaded by lnbits's patched `migrate_extension_database()` -under the `nostrmarket_fork` namespace in core `dbversions`, with the -following invariants: - - Every ALTER must be idempotent (use `_alter_drop_column_safe`-style - wrappers or `IF NOT EXISTS` / `IF EXISTS` predicates) so re-runs - are no-ops on already-migrated installs. - - Schema changes here MUST NOT depend on the version of upstream's - `migrations.py` they're running against — upstream rebases must - not require this file to be edited. - -See `aiolabs/lnbits#8` for the pattern + `aiolabs/lnbits/core/services/ -signer_migration.py` for the prior art on `_alter_*_safe` helpers. -""" - -from loguru import logger - - -async def _drop_column_safe(db, table: str, column: str) -> None: - """SQLite-safe drop-column. Newer SQLite (3.35+) supports - `ALTER TABLE … DROP COLUMN`; older versions need the classic - create-new-table + copy + swap dance. Postgres handles - `ALTER TABLE … DROP COLUMN IF EXISTS` natively. - - Idempotent: catches "no such column" + "column does not exist" - so re-runs are no-ops. - """ - try: - # Postgres path (supports IF EXISTS natively); also works on - # SQLite ≥ 3.35. - await db.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS {column};") - return - except Exception as exc: - # SQLite < 3.35 doesn't support IF EXISTS; fall through to the - # bare DROP COLUMN attempt + swallow the not-found case. - msg = str(exc).lower() - if "syntax" not in msg and "if exists" not in msg: - # Something other than the IF-EXISTS unsupported case; surface. - raise - - try: - await db.execute(f"ALTER TABLE {table} DROP COLUMN {column};") - except Exception as exc: - msg = str(exc).lower() - if "no such column" in msg or "does not exist" in msg: - # Already dropped; idempotent skip. - return - raise - - -async def m001_aio_drop_merchant_private_key(db): - """Drop the legacy `nostrmarket.merchants.private_key` column. - - Per aiolabs/nostrmarket#5, the merchant's signing identity is owned - by the lnbits-side account: signing routes through - `resolve_signer(account).sign_event(...)` (which dispatches to - `RemoteBunkerSigner` post-aiolabs/lnbits#18 phase 2.x). The nsec - never lives in this extension's storage. Dropping the column makes - that contract enforced at the schema level rather than relying on - "nobody writes to it anymore." - - Idempotent: re-runs no-op via `_drop_column_safe`. - """ - logger.info( - "[NOSTRMARKET fork] m001: dropping merchants.private_key " - "(aiolabs/nostrmarket#5 — signing routes through lnbits NostrSigner)" - ) - await _drop_column_safe(db, "nostrmarket.merchants", "private_key") - logger.info("[NOSTRMARKET fork] m001: done") diff --git a/models.py b/models.py index 6a4ae3b..b12b775 100644 --- a/models.py +++ b/models.py @@ -7,6 +7,12 @@ 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 ######################################## @@ -42,8 +48,6 @@ class MerchantConfig(MerchantProfile): # 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): @@ -51,22 +55,39 @@ class CreateMerchantRequest(BaseModel): class PartialMerchant(BaseModel): + private_key: str public_key: str config: MerchantConfig = MerchantConfig() class Merchant(PartialMerchant, Nostrable): id: str - user_id: str time: Optional[int] = 0 - # NOTE (aiolabs/nostrmarket#5): no `sign_hash` / `encrypt_message` / - # `decrypt_message` methods on Merchant anymore. Signing + NIP-44 crypto - # for a merchant goes through the lnbits `NostrSigner` abstraction - # (`resolve_signer(account)`); merchant is now pure metadata pointing - # at its owning account via `user_id`. The bunker (`RemoteBunkerSigner`) - # holds the merchant's nsec — lnbits never has it server-side. - # See `services._resolve_merchant_signer()` for the resolution helper. + def sign_hash(self, hash_: bytes) -> str: + return sign_message_hash(self.private_key, hash_) + + def decrypt_message(self, encrypted_message: str, public_key: str) -> str: + encryption_key = get_shared_secret(self.private_key, public_key) + return decrypt_message(encrypted_message, encryption_key) + + def encrypt_message(self, clear_text_message: str, public_key: str) -> str: + encryption_key = get_shared_secret(self.private_key, public_key) + return encrypt_message(clear_text_message, encryption_key) + + def build_dm_event(self, message: str, to_pubkey: str) -> NostrEvent: + content = self.encrypt_message(message, to_pubkey) + event = NostrEvent( + pubkey=self.public_key, + created_at=round(time.time()), + kind=4, + tags=[["p", to_pubkey]], + content=content, + ) + event.id = event.event_id + event.sig = self.sign_hash(bytes.fromhex(event.id)) + + return event @classmethod def from_row(cls, row: dict) -> "Merchant": diff --git a/nostr/nip44.py b/nostr/nip44.py deleted file mode 100644 index 908ad8a..0000000 --- a/nostr/nip44.py +++ /dev/null @@ -1,180 +0,0 @@ -""" -NIP-44 v2: Encrypted Payloads (Versioned) - -secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256, base64 - -Reference: https://github.com/nostr-protocol/nips/blob/master/44.md -""" - -import base64 -import hashlib -import hmac -import math -import secrets -import struct - -import coincurve -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms -from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand -from cryptography.hazmat.primitives import hashes - -VERSION = 2 -MIN_PLAINTEXT_SIZE = 1 -MAX_PLAINTEXT_SIZE = 65535 - - -def get_conversation_key(private_key_hex: str, public_key_hex: str) -> bytes: - """ - Calculate long-term conversation key between two users via ECDH + HKDF-extract. - Symmetric: get_conversation_key(a, B) == get_conversation_key(b, A) - """ - pk = coincurve.PublicKey(bytes.fromhex("02" + public_key_hex)) - sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex)) - shared_point = pk.multiply(sk.secret) - shared_x = shared_point.format(compressed=False)[1:33] - - # HKDF-extract only (not expand) with salt='nip44-v2' - conversation_key = hmac.new(b"nip44-v2", shared_x, hashlib.sha256).digest() - return conversation_key - - -def get_message_keys( - conversation_key: bytes, nonce: bytes -) -> tuple[bytes, bytes, bytes]: - """ - Derive per-message keys from conversation_key and nonce using HKDF-expand. - Returns (chacha_key, chacha_nonce, hmac_key). - """ - if len(conversation_key) != 32: - raise ValueError("invalid conversation_key length") - if len(nonce) != 32: - raise ValueError("invalid nonce length") - - keys = HKDFExpand( - algorithm=hashes.SHA256(), - length=76, - info=nonce, - ).derive(conversation_key) - - chacha_key = keys[0:32] - chacha_nonce = keys[32:44] - hmac_key = keys[44:76] - return chacha_key, chacha_nonce, hmac_key - - -def calc_padded_len(unpadded_len: int) -> int: - """Calculate padded length using power-of-two chunking.""" - if unpadded_len <= 0: - raise ValueError("invalid plaintext length") - if unpadded_len <= 32: - return 32 - next_power = 1 << (math.floor(math.log2(unpadded_len - 1)) + 1) - if next_power <= 256: - chunk = 32 - else: - chunk = next_power // 8 - return chunk * (math.floor((unpadded_len - 1) / chunk) + 1) - - -def _pad(plaintext: str) -> bytes: - """Convert plaintext string to padded byte array.""" - unpadded = plaintext.encode("utf-8") - unpadded_len = len(unpadded) - if unpadded_len < MIN_PLAINTEXT_SIZE or unpadded_len > MAX_PLAINTEXT_SIZE: - raise ValueError( - f"invalid plaintext length: {unpadded_len} " - f"(must be {MIN_PLAINTEXT_SIZE}..{MAX_PLAINTEXT_SIZE})" - ) - prefix = struct.pack(">H", unpadded_len) - padded_len = calc_padded_len(unpadded_len) - suffix = b"\x00" * (padded_len - unpadded_len) - return prefix + unpadded + suffix - - -def _unpad(padded: bytes) -> str: - """Convert padded byte array back to plaintext string.""" - unpadded_len = struct.unpack(">H", padded[0:2])[0] - unpadded = padded[2 : 2 + unpadded_len] - if ( - unpadded_len == 0 - or len(unpadded) != unpadded_len - or len(padded) != 2 + calc_padded_len(unpadded_len) - ): - raise ValueError("invalid padding") - return unpadded.decode("utf-8") - - -def _hmac_aad(key: bytes, message: bytes, aad: bytes) -> bytes: - """HMAC-SHA256 with AAD: hmac(key, aad || message).""" - if len(aad) != 32: - raise ValueError("AAD associated data must be 32 bytes") - return hmac.new(key, aad + message, hashlib.sha256).digest() - - -def _chacha20(key: bytes, nonce: bytes, data: bytes) -> bytes: - """ChaCha20 encrypt/decrypt with initial counter = 0.""" - # cryptography's ChaCha20 takes a 16-byte nonce: 4-byte counter (LE) + 12-byte nonce - full_nonce = b"\x00\x00\x00\x00" + nonce - cipher = Cipher(algorithms.ChaCha20(key, full_nonce), mode=None) - encryptor = cipher.encryptor() - return encryptor.update(data) + encryptor.finalize() - - -def _decode_payload(payload: str) -> tuple[bytes, bytes, bytes]: - """Decode base64 payload into (nonce, ciphertext, mac).""" - plen = len(payload) - if plen == 0 or payload[0] == "#": - raise ValueError("unknown version") - if plen < 132 or plen > 87472: - raise ValueError("invalid payload size") - - data = base64.b64decode(payload) - dlen = len(data) - if dlen < 99 or dlen > 65603: - raise ValueError("invalid data size") - - vers = data[0] - if vers != VERSION: - raise ValueError(f"unknown version {vers}") - - nonce = data[1:33] - ciphertext = data[33 : dlen - 32] - mac = data[dlen - 32 : dlen] - return nonce, ciphertext, mac - - -def encrypt( - plaintext: str, - conversation_key: bytes, - nonce: bytes | None = None, -) -> str: - """ - Encrypt plaintext using NIP-44 v2. - Returns base64-encoded payload. - """ - if nonce is None: - nonce = secrets.token_bytes(32) - if len(nonce) != 32: - raise ValueError("invalid nonce length") - - chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce) - padded = _pad(plaintext) - ciphertext = _chacha20(chacha_key, chacha_nonce, padded) - mac = _hmac_aad(hmac_key, ciphertext, nonce) - return base64.b64encode( - struct.pack("B", VERSION) + nonce + ciphertext + mac - ).decode("ascii") - - -def decrypt(payload: str, conversation_key: bytes) -> str: - """ - Decrypt a NIP-44 v2 base64 payload. - Returns plaintext string. - """ - nonce, ciphertext, mac = _decode_payload(payload) - chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce) - calculated_mac = _hmac_aad(hmac_key, ciphertext, nonce) - if not hmac.compare_digest(calculated_mac, mac): - raise ValueError("invalid MAC") - padded_plaintext = _chacha20(chacha_key, chacha_nonce, ciphertext) - return _unpad(padded_plaintext) diff --git a/nostr/nip59.py b/nostr/nip59.py deleted file mode 100644 index 19cc718..0000000 --- a/nostr/nip59.py +++ /dev/null @@ -1,231 +0,0 @@ -""" -NIP-59: Gift Wrap - -Three-layer protocol for metadata-protected messaging: - 1. Rumor (unsigned event) — carries content, deniable if leaked - 2. Seal (kind 13) — encrypts rumor, signed by author, no recipient metadata - 3. Gift Wrap (kind 1059) — encrypts seal with ephemeral key, has recipient p-tag - -Reference: https://github.com/nostr-protocol/nips/blob/master/59.md - -## Bunker integration (aiolabs/nostrmarket#5) - -Merchant-identity layers (rumor's sender-pubkey + seal's encryption + -seal's signature) route through the lnbits `NostrSigner` abstraction -so the merchant's nsec stays in the bunker — never reaches this -process. Specifically: - -- `create_seal` is async; takes a `sender_signer` instead of a - plaintext nsec. The seal's NIP-44 encrypt + Schnorr sign happen - via `await sender_signer.nip44_encrypt(...)` + - `await sender_signer.sign_event(...)` over the NIP-46 channel. -- `unwrap_gift_wrap` + `unseal` are async; take a `recipient_signer` - and call `await recipient_signer.nip44_decrypt(...)` for each layer. - -The **ephemeral keypair layer** (`create_gift_wrap`) stays synchronous -+ local: the ephemeral nsec exists for the lifetime of one wrap and -provides no merchant-identity capability, so there's no reason to -involve the bunker. Generating it locally avoids one round-trip per -DM. -""" - -import json -import secrets -import time -from typing import Optional - -import coincurve - -from .event import NostrEvent -from .nip44 import decrypt as nip44_decrypt -from .nip44 import encrypt as nip44_encrypt -from .nip44 import get_conversation_key - -TWO_DAYS = 2 * 24 * 60 * 60 - - -def _random_past_timestamp() -> int: - """Generate a timestamp randomly in the past 0-2 days for metadata protection.""" - return int(time.time()) - secrets.randbelow(TWO_DAYS) - - -def _sign_event_local(event: NostrEvent, private_key_hex: str) -> NostrEvent: - """Compute event id and sign it locally with a privkey held in this - process. Used only for the ephemeral-keypair layer (gift wrap outer); - merchant-identity sign goes through the signer ABC instead.""" - event.id = event.event_id - sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex)) - event.sig = sk.sign_schnorr(bytes.fromhex(event.id)).hex() - return event - - -def _pubkey_from_privkey(private_key_hex: str) -> str: - """Derive x-only public key hex from private key hex.""" - sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex)) - return sk.public_key.format(compressed=True)[1:].hex() - - -def create_rumor( - pubkey: str, - content: str, - kind: int = 14, - tags: Optional[list[list[str]]] = None, - created_at: Optional[int] = None, -) -> NostrEvent: - """ - Create an unsigned rumor event. - The event has an id but no signature, making it deniable. - """ - event = NostrEvent( - pubkey=pubkey, - created_at=created_at or int(time.time()), - kind=kind, - tags=tags or [], - content=content, - ) - event.id = event.event_id - # sig intentionally left as None (unsigned) - return event - - -async def create_seal( - rumor: NostrEvent, - sender_signer, - recipient_pubkey: str, -) -> NostrEvent: - """ - Create a kind 13 seal: encrypts the rumor for the recipient. - Signed by the sender. Tags are always empty. - - Both crypto operations (NIP-44 encrypt + Schnorr sign) route - through the sender's `NostrSigner` (`sender_signer`) — the - plaintext nsec is never observable in this process. - """ - encrypted_rumor = await sender_signer.nip44_encrypt( - rumor.stringify(), recipient_pubkey - ) - - seal = NostrEvent( - pubkey=sender_signer.pubkey, - created_at=_random_past_timestamp(), - kind=13, - tags=[], - content=encrypted_rumor, - ) - # The signer fills id + sig (computed bunker-side). - signed = await sender_signer.sign_event( - { - "pubkey": seal.pubkey, - "created_at": seal.created_at, - "kind": seal.kind, - "tags": seal.tags, - "content": seal.content, - } - ) - seal.id = signed["id"] - seal.sig = signed["sig"] - return seal - - -def create_gift_wrap( - seal: NostrEvent, - recipient_pubkey: str, -) -> NostrEvent: - """ - Create a kind 1059 gift wrap: encrypts the seal with an ephemeral key. - The only public metadata is the recipient's p-tag. - - Stays synchronous + local: the ephemeral nsec exists only for the - lifetime of one wrap and provides no merchant-identity capability, - so there's no point routing through the bunker (would add one NIP-46 - round-trip per DM with zero security benefit). - """ - ephemeral_privkey = secrets.token_bytes(32).hex() - ephemeral_pubkey = _pubkey_from_privkey(ephemeral_privkey) - - conv_key = get_conversation_key(ephemeral_privkey, recipient_pubkey) - encrypted_seal = nip44_encrypt(seal.stringify(), conv_key) - - wrap = NostrEvent( - pubkey=ephemeral_pubkey, - created_at=_random_past_timestamp(), - kind=1059, - tags=[["p", recipient_pubkey]], - content=encrypted_seal, - ) - return _sign_event_local(wrap, ephemeral_privkey) - - -async def unwrap_gift_wrap( - gift_wrap: NostrEvent, - recipient_signer, -) -> NostrEvent: - """ - Decrypt a kind 1059 gift wrap to reveal the inner seal. - Routes NIP-44 decrypt through the recipient's signer abstraction - so the recipient's nsec stays in the bunker. - """ - seal_json = await recipient_signer.nip44_decrypt( - gift_wrap.content, gift_wrap.pubkey - ) - return NostrEvent(**json.loads(seal_json)) - - -async def unseal( - seal: NostrEvent, - recipient_signer, -) -> NostrEvent: - """ - Decrypt a kind 13 seal to reveal the inner rumor. - Uses the recipient signer (their nsec stays in the bunker) and the - seal's pubkey (the sender). Validates that the rumor's pubkey - matches the seal's pubkey. - """ - rumor_json = await recipient_signer.nip44_decrypt(seal.content, seal.pubkey) - rumor = NostrEvent(**json.loads(rumor_json)) - - if rumor.pubkey != seal.pubkey: - raise ValueError( - f"rumor pubkey ({rumor.pubkey}) does not match " - f"seal pubkey ({seal.pubkey})" - ) - return rumor - - -# --- Convenience functions --- - - -async def wrap_message( - content: str, - sender_signer, - recipient_pubkey: str, - kind: int = 14, - tags: Optional[list[list[str]]] = None, -) -> NostrEvent: - """ - Full wrap pipeline: create rumor → seal → gift wrap. - Returns the gift wrap event ready to publish. - - `sender_signer` is the sender merchant's `NostrSigner` (post-#5: - always a `RemoteBunkerSigner`). The merchant's nsec never leaves - the bunker. - """ - rumor = create_rumor(sender_signer.pubkey, content, kind=kind, tags=tags) - seal = await create_seal(rumor, sender_signer, recipient_pubkey) - return create_gift_wrap(seal, recipient_pubkey) - - -async def unwrap_message( - gift_wrap: NostrEvent, - recipient_signer, -) -> NostrEvent: - """ - Full unwrap pipeline: gift wrap → seal → rumor. - Returns the rumor with sender pubkey and plaintext content. - - `recipient_signer` is the recipient merchant's `NostrSigner`. Both - NIP-44 decrypt layers (gift wrap → seal, seal → rumor) route - through the signer abstraction. - """ - seal = await unwrap_gift_wrap(gift_wrap, recipient_signer) - return await unseal(seal, recipient_signer) diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index dbc410e..967bc1b 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -1,8 +1,6 @@ import asyncio import json -import time from asyncio import Queue -from collections import OrderedDict from threading import Thread from typing import Callable, List, Optional @@ -14,8 +12,6 @@ from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash from .event import NostrEvent -MAX_SEEN_EVENTS = 1000 - class NostrClient: def __init__(self): @@ -24,8 +20,6 @@ class NostrClient: self.ws: Optional[WebSocketApp] = None self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32] self.running = False - self._seen_events: OrderedDict[str, None] = OrderedDict() - self.last_event_at: float = 0 @property def is_websocket_connected(self): @@ -70,21 +64,11 @@ 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): @@ -150,16 +134,13 @@ class NostrClient: logger.debug(ex) def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List: - # NIP-17/NIP-59: subscribe to kind 1059 gift wraps addressed to our merchants. - # With gift wrapping, outgoing messages are self-wrapped (same p-tag filter). - # - # Do NOT apply `since` here. Per NIP-59, gift wraps use randomized past - # timestamps (up to 2 days back) to defeat metadata correlation, so a - # `since` derived from the latest DM in our DB will reject fresh wraps - # whose randomized created_at is older than that window. Server-side - # dedup + the client's is_duplicate_event() guard handle replays. - gift_wrap_filter: dict = {"kinds": [1059], "#p": public_keys} - return [gift_wrap_filter] + in_messages_filter = {"kinds": [4], "#p": public_keys} + out_messages_filter = {"kinds": [4], "authors": public_keys} + if since and since != 0: + in_messages_filter["since"] = since + out_messages_filter["since"] = since + + return [in_messages_filter, out_messages_filter] def _filters_for_stall_events(self, public_keys: List[str], since: int) -> List: stall_filter = {"kinds": [30017], "authors": public_keys} diff --git a/services.py b/services.py index 444a9d5..3057292 100644 --- a/services.py +++ b/services.py @@ -3,10 +3,8 @@ import json from typing import List, Optional, Tuple from lnbits.bolt11 import decode -from lnbits.core.crud import get_account, get_wallet +from lnbits.core.crud import get_wallet from lnbits.core.services import create_invoice, websocket_updater -from lnbits.core.signers import resolve_signer -from lnbits.core.signers.base import NostrSigner, SignerError from loguru import logger from . import nostr_client @@ -14,11 +12,9 @@ from .crud import ( CustomerProfile, create_customer, create_direct_message, - create_merchant, create_order, create_product, create_stall, - create_zone, get_customer, get_last_direct_messages_created_at, get_last_product_update_time, @@ -46,7 +42,6 @@ from .models import ( DirectMessage, DirectMessageType, Merchant, - MerchantConfig, Nostrable, Order, OrderContact, @@ -54,16 +49,13 @@ 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( @@ -173,173 +165,20 @@ async def update_merchant_to_nostr( return merchant -async def _resolve_merchant_signer(merchant: Merchant) -> NostrSigner: - """Resolve the lnbits NostrSigner for a merchant's owning account. - - Post-#5 (aiolabs/nostrmarket#5), the merchant's nsec lives in the - bunker via the account's `signer_config`. No fast-path or caching - today — per-call lookup is fine for v1 throughput; if the events - extension or DM hot path becomes contended, revisit with a - process-local cache keyed on `merchant.user_id`. - - Raises `SignerError` if the account can't be found or its signer - can't be resolved — callers should propagate, not silently skip, - so misconfigured rows surface loudly. - """ - account = await get_account(merchant.user_id) - if account is None: - raise SignerError( - f"merchant {merchant.id[:8]} references missing account " - f"{merchant.user_id[:8]} — can't resolve signer" - ) - return resolve_signer(account) - - async def sign_and_send_to_nostr( merchant: Merchant, n: Nostrable, delete=False ) -> NostrEvent: - """Sign + publish a Nostrable as the merchant's identity. - - Signing routes through the merchant's account `NostrSigner` (post-#5). - The signer fills `id` + `sig` server-side (bunker for the - `RemoteBunkerSigner` case) — this function builds the unsigned dict - shape, hands it to the signer, and copies the result back onto the - `NostrEvent` instance for the publisher. - """ event = ( n.to_nostr_delete_event(merchant.public_key) if delete else n.to_nostr_event(merchant.public_key) ) - - signer = await _resolve_merchant_signer(merchant) - signed = await signer.sign_event( - { - "pubkey": event.pubkey, - "created_at": event.created_at, - "kind": event.kind, - "tags": event.tags, - "content": event.content, - } - ) - event.id = signed["id"] - event.sig = signed["sig"] + event.sig = merchant.sign_hash(bytes.fromhex(event.id)) await nostr_client.publish_nostr_event(event) return event -async def provision_merchant( - user_id: str, - wallet_id: str, - public_key: str, - display_name: Optional[str] = None, - config: Optional[MerchantConfig] = None, -) -> Merchant: - """ - Provision a merchant with a default shipping zone and default stall, - and publish the stall to Nostr relays. - - Post-aiolabs/nostrmarket#5: no `private_key` argument. The merchant - identity IS the lnbits account's identity (`public_key` parameter - must equal `account.pubkey` for the same `user_id`); signing routes - through the account's `NostrSigner` (`RemoteBunkerSigner` in the - target deployment). The merchant nsec lives in the bunker, never - server-side. - - Single source of truth used by: - - LNbits user-creation hook (eager, on signup) — see - lnbits/core/services/users.py:_create_default_merchant - - nostrmarket views_api._auto_create_merchant (lazy, on first GET - /api/v1/merchant when a merchant is missing). - - Idempotent on the merchant: if a merchant with this pubkey already - exists, returns it without recreating zone/stall. - """ - existing = await get_merchant_by_pubkey(public_key) - if existing: - return existing - - partial_merchant = PartialMerchant( - public_key=public_key, - config=config or MerchantConfig(), - ) - merchant = await create_merchant(user_id, partial_merchant) - - online_zone = Zone( - id=f"online-{merchant.public_key}", - name="Online", - currency="sat", - cost=0, - countries=["Free (digital)"], - ) - await create_zone(merchant.id, online_zone) - - raw_owner_name = display_name or "My" - owner_name = raw_owner_name[:1].upper() + raw_owner_name[1:] - default_stall = Stall( - wallet=wallet_id, - name=f"{owner_name}'s Store", - currency="sat", - shipping_zones=[online_zone], - ) - default_stall = await create_stall(merchant.id, default_stall) - - # Publish the kind 30017 stall event so customers' clients can resolve - # the stall name when they fetch products. Non-fatal on failure: a - # later product publish (or webapp self-heal) will retry. - # - # Fire-and-forget: `nostr_client.publish_nostr_event` has no per-relay - # deadline and will block indefinitely if every configured relay is - # unreachable (cf. aiolabs/nostrmarket#7). When `provision_merchant` - # is called from the eager signup hook (lnbits/core/services/users.py - # ::_create_default_merchant, aiolabs/lnbits#46), inline-awaiting that - # publish hangs the uvicorn worker on `POST /auth/register` forever. - # The DB rows we just wrote are sufficient to serve the wallet UI; - # the stall event_id gets backfilled when the publish completes (or - # stays NULL until a later resubscribe-driven republish lands it). - asyncio.create_task( - _publish_default_stall_background(merchant.id, merchant, default_stall) - ) - - return merchant - - -# Generous bound: signing through the bunker can take 1–2 s on a cold -# session, plus the relay publish itself. 30 s is well over both, and -# the cap matters only when the relay set is unreachable. -STALL_PUBLISH_TIMEOUT_S = 30.0 - - -async def _publish_default_stall_background( - merchant_id: str, merchant: Merchant, default_stall: Stall -) -> None: - """Background helper for `provision_merchant`'s default-stall publish. - - Bounded by `STALL_PUBLISH_TIMEOUT_S` so even a permanently-unreachable - relay set doesn't pin an asyncio task forever. Errors and timeouts are - logged at warning — never raised, since the caller scheduled-and-forgot. - """ - try: - stall_event = await asyncio.wait_for( - sign_and_send_to_nostr(merchant, default_stall), - timeout=STALL_PUBLISH_TIMEOUT_S, - ) - default_stall.event_id = stall_event.id - await update_stall(merchant_id, default_stall) - except asyncio.TimeoutError: - logger.warning( - f"[NOSTRMARKET] Default stall publish for merchant " - f"{merchant_id} timed out after {STALL_PUBLISH_TIMEOUT_S}s; " - f"event_id stays NULL until a later republish lands it" - ) - except Exception as ex: - logger.warning( - f"[NOSTRMARKET] Failed to publish default stall for " - f"merchant {merchant_id}: {ex}" - ) - - async def handle_order_paid(order_id: str, merchant_pubkey: str): try: order = await update_order_paid_status(order_id, True) @@ -431,37 +270,19 @@ async def send_dm( other_pubkey: str, type_: int, dm_content: str, -) -> DirectMessage: - # Post-#5: nsec stays in the bunker; both the to-recipient wrap and - # the to-self archival wrap route their seal-layer crypto through - # the merchant's NostrSigner. - signer = await _resolve_merchant_signer(merchant) - - # Wrap message to recipient via NIP-59 gift wrap - gift_wrap = await wrap_message( - dm_content, - signer, - other_pubkey, - ) +): + dm_event = merchant.build_dm_event(dm_content, other_pubkey) dm = PartialDirectMessage( - event_id=gift_wrap.id, - event_created_at=gift_wrap.created_at, + event_id=dm_event.id, + event_created_at=dm_event.created_at, message=dm_content, public_key=other_pubkey, type=type_, ) dm_reply = await create_direct_message(merchant.id, dm) - await nostr_client.publish_nostr_event(gift_wrap) - - # Also wrap a copy to self for archival - self_wrap = await wrap_message( - dm_content, - signer, - merchant.public_key, - ) - await nostr_client.publish_nostr_event(self_wrap) + await nostr_client.publish_nostr_event(dm_event) await websocket_updater( merchant.id, @@ -474,8 +295,6 @@ async def send_dm( ), ) - return dm_reply - async def compute_products_new_quantity( merchant_id: str, product_ids: List[str], items: List[OrderItem] @@ -513,15 +332,11 @@ async def process_nostr_message(msg: str): return _, event = rest event = NostrEvent(**event) - - # Deduplicate events (overlap resubscriptions may deliver duplicates) - if nostr_client.is_duplicate_event(event.id): - return - + if event.kind == 0: await _handle_customer_profile_update(event) - elif event.kind == 1059: - await _handle_gift_wrap(event) + elif event.kind == 4: + await _handle_nip04_message(event) elif event.kind == 30017: await _handle_stall(event) elif event.kind == 30018: @@ -615,42 +430,30 @@ async def extract_customer_order_from_dm( return order -async def _handle_gift_wrap(event: NostrEvent): - """Handle an incoming kind 1059 gift wrap event (NIP-59/NIP-17).""" - +async def _handle_nip04_message(event: NostrEvent): + p_tags = event.tag_values("p") - if not p_tags: - logger.warning(f"[NOSTRMARKET] ⚠️ Gift wrap has no p-tag: {event.id}") - return - - # The p-tag identifies the recipient of the gift wrap - recipient_pubkey = p_tags[0] - merchant = await get_merchant_by_pubkey(recipient_pubkey) - if not merchant: - logger.warning( - f"[NOSTRMARKET] ⚠️ No merchant found for gift wrap recipient: {recipient_pubkey}" + + # 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] ) - return - - try: - recipient_signer = await _resolve_merchant_signer(merchant) - rumor = await unwrap_message(event, recipient_signer) - except Exception as ex: - logger.error(f"[NOSTRMARKET] ❌ Failed to unwrap gift wrap {event.id}: {ex}") - return - - sender_pubkey = rumor.pubkey - - if sender_pubkey == merchant.public_key: - # This is a self-addressed wrap (outgoing message archive) - # Extract the actual recipient from the rumor's p-tags - rumor_p_tags = rumor.tag_values("p") - if rumor_p_tags: - await _handle_outgoing_dms(rumor, merchant, rumor.content) - return - - # Incoming message from a customer - await _handle_incoming_dms(rumor, merchant, rumor.content) + await _handle_outgoing_dms(event, sender_merchant, clear_text_msg) + return # IMPORTANT: Return immediately + + # No merchant found in either direction async def _handle_incoming_dms( @@ -750,21 +553,16 @@ async def _persist_dm( async def reply_to_structured_dm( merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str ): - signer = await _resolve_merchant_signer(merchant) - gift_wrap = await wrap_message( - dm_reply, - signer, - customer_pubkey, - ) + dm_event = merchant.build_dm_event(dm_reply, customer_pubkey) dm = PartialDirectMessage( - event_id=gift_wrap.id, - event_created_at=gift_wrap.created_at, + event_id=dm_event.id, + event_created_at=dm_event.created_at, message=dm_reply, public_key=customer_pubkey, type=dm_type, ) await create_direct_message(merchant.id, dm) - await nostr_client.publish_nostr_event(gift_wrap) + await nostr_client.publish_nostr_event(dm_event) await websocket_updater( merchant.id, diff --git a/static/components/merchant-tab.js b/static/components/merchant-tab.js index d993bd4..9595c86 100644 --- a/static/components/merchant-tab.js +++ b/static/components/merchant-tab.js @@ -19,7 +19,9 @@ window.app.component('merchant-tab', { 'merchant-deleted', 'toggle-merchant-state', 'restart-nostr-connection', - 'profile-updated' + 'profile-updated', + 'import-key', + 'generate-key' ], data: function () { return { diff --git a/static/js/index.js b/static/js/index.js index f5d2e62..b10220c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -13,6 +13,19 @@ window.app = Vue.createApp({ orderPubkey: null, showKeys: false, stallCount: 0, + importKeyDialog: { + show: false, + data: { + privateKey: null + } + }, + generateKeyDialog: { + show: false, + privateKey: null, + nsec: null, + npub: null, + showNsec: false + }, wsConnection: null, nostrStatus: { connected: false, @@ -36,29 +49,22 @@ window.app = Vue.createApp({ } }, methods: { - migrateKeys: async function () { - LNbits.utils - .confirmDialog( - 'This will update your merchant to use your current account Nostr keypair ' + - 'and republish all stalls and products under the new identity. ' + - 'Existing orders and messages are preserved. Continue?' - ) - .onOk(async () => { - try { - const {data} = await LNbits.api.request( - 'POST', - `/nostrmarket/api/v1/merchant/${this.merchant.id}/migrate-keys`, - this.g.user.wallets[0].adminkey - ) - this.merchant = data - this.$q.notify({ - type: 'positive', - message: 'Merchant keys migrated and stalls republished' - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }) + generateKeys: async function () { + // 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 + // 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 }, toggleShowKeys: function () { this.showKeys = !this.showKeys @@ -373,11 +379,7 @@ window.app = Vue.createApp({ } }, created: async function () { - const merchant = await this.getMerchant() - if (!merchant) { - // Auto-create merchant using the account's existing Nostr keypair - await this.createMerchant() - } + await this.getMerchant() await this.checkNostrStatus() setInterval(async () => { if ( diff --git a/tasks.py b/tasks.py index c147936..774951f 100644 --- a/tasks.py +++ b/tasks.py @@ -1,5 +1,4 @@ import asyncio -import time from asyncio import Queue from lnbits.core.models import Payment @@ -10,13 +9,9 @@ from .nostr.nostr_client import NostrClient from .services import ( handle_order_paid, process_nostr_message, - resubscribe_to_all_merchants, subscribe_to_all_merchants, ) -HEALTH_CHECK_INTERVAL = 30 # seconds between health checks -STALE_THRESHOLD = 120 # seconds without events before resubscribing - async def wait_for_paid_invoices(): invoice_queue = Queue() @@ -40,38 +35,17 @@ 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") + logger.info("[NOSTRMARKET DEBUG] Starting wait_for_nostr_events task") while True: try: - logger.info("[NOSTRMARKET] Subscribing to all merchants...") + 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"[NOSTRMARKET] Subscription failed. Retrying in 10s: {e}") + logger.warning(f"[NOSTRMARKET DEBUG] Subscription failed. Will retry in 10 seconds: {e}") await asyncio.sleep(10) - - -async def subscription_health_monitor(nostr_client: NostrClient): - """ - Periodically check if events are flowing. If no events have been - received for STALE_THRESHOLD seconds, force a resubscription with - overlap to catch any missed events. - """ - logger.info("[NOSTRMARKET] Starting subscription health monitor") - while True: - await asyncio.sleep(HEALTH_CHECK_INTERVAL) - try: - if not nostr_client.is_websocket_connected: - continue - - elapsed = time.time() - nostr_client.last_event_at - if nostr_client.last_event_at > 0 and elapsed > STALE_THRESHOLD: - logger.warning( - f"[NOSTRMARKET] ⚠️ No events for {elapsed:.0f}s, resubscribing..." - ) - await resubscribe_to_all_merchants() - except Exception as e: - logger.error(f"[NOSTRMARKET] Health monitor error: {e}") diff --git a/templates/nostrmarket/components/merchant-tab.html b/templates/nostrmarket/components/merchant-tab.html index 497478a..8ce6624 100644 --- a/templates/nostrmarket/components/merchant-tab.html +++ b/templates/nostrmarket/components/merchant-tab.html @@ -64,6 +64,25 @@ + + + + + + + Import Existing Key + Use an existing nsec + + + + + + + + Generate New Key + Create a fresh nsec + +
- - - Your account Nostr keypair has changed since this merchant was created. - The merchant is still using the old key. Migrate to republish your - stalls and products under the new identity. - -
@@ -142,9 +124,58 @@
- - -
Setting up Nostr Market...
+ + Welcome to Nostr Market!
+ In Nostr Market, merchant and customer communicate via NOSTR relays, so + loss of money, product information, and reputation become far less + likely if attacked. +
+ + Terms
+
    +
  • + merchant - seller of products with + NOSTR key-pair +
  • +
  • + customer - buyer of products with + NOSTR key-pair +
  • +
  • + product - item for sale by the + merchant +
  • +
  • + stall - list of products controlled + by merchant (a merchant can have multiple stalls) +
  • +
  • + marketplace - clientside software for + searching stalls and purchasing products +
  • +
+
+ +
+
+ + Use an existing private key (hex or npub) + + + A new key pair will be generated for you + +
+
@@ -365,6 +396,89 @@
+
+ + + + +
+ Import + Cancel +
+
+
+
+
+ + + + +
Generate New Key
+
+
Public Key (npub)
+ + + +
+
+
+ + Private Key (nsec) +
+ + + +
+ + Never share your private key! +
+
+
+ Create Merchant + Cancel +
+
+
{% endblock%}{% block scripts %} {{ window_vars(user) }} diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 22ffb83..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Stub out the nostrmarket root package and all LNbits dependencies so that -nostr/* unit tests can run without the full LNbits environment. - -pytest walks up from tests/ and tries to import the parent __init__.py, -which pulls in fastapi, lnbits, websocket, etc. We preemptively register -the parent package as a simple module so that import never happens. -""" - -import sys -import types -from pathlib import Path - -# Register 'nostrmarket' as an already-imported namespace package -# pointing at the extension root, so pytest doesn't try to exec __init__.py -_ext_root = Path(__file__).resolve().parent.parent -_pkg = types.ModuleType("nostrmarket") -_pkg.__path__ = [str(_ext_root)] -_pkg.__package__ = "nostrmarket" -sys.modules["nostrmarket"] = _pkg - -# Also ensure the nostr subpackage is importable -_nostr_dir = _ext_root / "nostr" -_nostr_pkg = types.ModuleType("nostrmarket.nostr") -_nostr_pkg.__path__ = [str(_nostr_dir)] -_nostr_pkg.__package__ = "nostrmarket.nostr" -sys.modules["nostrmarket.nostr"] = _nostr_pkg diff --git a/tests/test_nip44.py b/tests/test_nip44.py deleted file mode 100644 index 3e767a6..0000000 --- a/tests/test_nip44.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Tests for NIP-44 v2 encryption against official spec test vectors.""" - -import coincurve -import pytest - -from nostr.nip44 import ( - calc_padded_len, - decrypt, - encrypt, - get_conversation_key, - get_message_keys, -) - - -def pubkey_from_secret(secret_hex: str) -> str: - """Derive x-only public key hex from secret key hex.""" - sk = coincurve.PrivateKey(bytes.fromhex(secret_hex)) - return sk.public_key.format(compressed=True)[1:].hex() - - -# --- Test vector from NIP-44 spec --- - -SPEC_VECTOR = { - "sec1": "0000000000000000000000000000000000000000000000000000000000000001", - "sec2": "0000000000000000000000000000000000000000000000000000000000000002", - "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", - "nonce": "0000000000000000000000000000000000000000000000000000000000000001", - "plaintext": "a", - "payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb", -} - - -class TestConversationKey: - def test_spec_vector(self): - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - assert key.hex() == SPEC_VECTOR["conversation_key"] - - def test_symmetric(self): - """conv(a, B) == conv(b, A)""" - pub1 = pubkey_from_secret(SPEC_VECTOR["sec1"]) - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - key_ab = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - key_ba = get_conversation_key(SPEC_VECTOR["sec2"], pub1) - assert key_ab == key_ba - - -class TestMessageKeys: - def test_returns_correct_lengths(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - nonce = bytes.fromhex(SPEC_VECTOR["nonce"]) - chacha_key, chacha_nonce, hmac_key = get_message_keys(conv_key, nonce) - assert len(chacha_key) == 32 - assert len(chacha_nonce) == 12 - assert len(hmac_key) == 32 - - def test_rejects_bad_key_length(self): - with pytest.raises(ValueError): - get_message_keys(b"\x00" * 16, b"\x00" * 32) - - def test_rejects_bad_nonce_length(self): - with pytest.raises(ValueError): - get_message_keys(b"\x00" * 32, b"\x00" * 16) - - -class TestPadding: - @pytest.mark.parametrize( - "unpadded,expected", - [ - (1, 32), - (2, 32), - (31, 32), - (32, 32), - (33, 64), - (64, 64), - (65, 96), - (256, 256), - (257, 320), - (1024, 1024), - (65535, 65536), - ], - ) - def test_calc_padded_len(self, unpadded, expected): - assert calc_padded_len(unpadded) == expected - - def test_rejects_zero(self): - with pytest.raises(ValueError): - calc_padded_len(0) - - -class TestEncryptDecrypt: - def test_spec_vector(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - nonce = bytes.fromhex(SPEC_VECTOR["nonce"]) - payload = encrypt(SPEC_VECTOR["plaintext"], conv_key, nonce) - assert payload == SPEC_VECTOR["payload"] - - def test_spec_vector_decrypt(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - plaintext = decrypt(SPEC_VECTOR["payload"], conv_key) - assert plaintext == SPEC_VECTOR["plaintext"] - - def test_round_trip_short(self): - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - msg = "x" - assert decrypt(encrypt(msg, conv_key), conv_key) == msg - - def test_round_trip_long(self): - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - msg = "A" * 65535 - assert decrypt(encrypt(msg, conv_key), conv_key) == msg - - def test_round_trip_unicode(self): - pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"]) - conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2) - msg = "hello world! \U0001f680\U0001f30e\U0001f4ac" - assert decrypt(encrypt(msg, conv_key), conv_key) == msg - - def test_tampered_mac_rejected(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - payload = SPEC_VECTOR["payload"] - tampered = payload[:-1] + ("a" if payload[-1] != "a" else "b") - with pytest.raises(ValueError, match="invalid MAC"): - decrypt(tampered, conv_key) - - def test_empty_plaintext_rejected(self): - conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"]) - with pytest.raises(ValueError, match="invalid plaintext length"): - encrypt("", conv_key) - - def test_unknown_version_rejected(self): - with pytest.raises(ValueError, match="unknown version"): - decrypt("#invalid", bytes(32)) - - def test_short_payload_rejected(self): - with pytest.raises(ValueError, match="invalid payload size"): - decrypt("AAAA", bytes(32)) diff --git a/tests/test_nip59.py b/tests/test_nip59.py deleted file mode 100644 index 5751990..0000000 --- a/tests/test_nip59.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Tests for NIP-59 gift wrap protocol. - -Post-aiolabs/nostrmarket#5: the merchant-identity crypto operations -(`create_seal`, `unseal`, `unwrap_gift_wrap`, `wrap_message`, -`unwrap_message`) are async + take a `NostrSigner`-shaped object -instead of a raw privkey. These tests use a local-privkey-backed -fake signer so the NIP-59 plumbing can be tested in isolation — -the real runtime uses `RemoteBunkerSigner` against nsecbunkerd. -""" - -import json -import time - -import coincurve -import pytest - -from nostr.event import NostrEvent -from nostr.nip44 import decrypt as _nip44_decrypt -from nostr.nip44 import encrypt as _nip44_encrypt -from nostr.nip44 import get_conversation_key -from nostr.nip59 import ( - create_gift_wrap, - create_rumor, - create_seal, - unseal, - unwrap_gift_wrap, - unwrap_message, - wrap_message, -) - - -def _generate_keypair() -> tuple[str, str]: - """Generate a (privkey_hex, pubkey_hex) pair.""" - sk = coincurve.PrivateKey() - privkey = sk.secret.hex() - pubkey = sk.public_key.format(compressed=True)[1:].hex() - return privkey, pubkey - - -class _LocalSignerStub: - """Stand-in for the lnbits `NostrSigner` ABC backed by a held privkey. - - Provides just the surface the NIP-59 functions touch: - `pubkey`, `nip44_encrypt`, `nip44_decrypt`, `sign_event`. Useful for - unit-testing the NIP-59 plumbing without involving a bunker — the - crypto is identical, only the dispatch boundary differs. - """ - - def __init__(self, privkey_hex: str): - self._privkey = privkey_hex - sk = coincurve.PrivateKey(bytes.fromhex(privkey_hex)) - self.pubkey = sk.public_key.format(compressed=True)[1:].hex() - - async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str: - return _nip44_encrypt( - plaintext, get_conversation_key(self._privkey, peer_pubkey_hex) - ) - - async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str: - return _nip44_decrypt( - ciphertext, get_conversation_key(self._privkey, peer_pubkey_hex) - ) - - async def sign_event(self, unsigned: dict) -> dict: - evt = NostrEvent( - pubkey=unsigned["pubkey"], - created_at=unsigned["created_at"], - kind=unsigned["kind"], - tags=unsigned["tags"], - content=unsigned["content"], - ) - evt.id = evt.event_id - sk = coincurve.PrivateKey(bytes.fromhex(self._privkey)) - sig = sk.sign_schnorr(bytes.fromhex(evt.id)).hex() - return {**unsigned, "id": evt.id, "sig": sig} - - -SENDER_PRIV, SENDER_PUB = _generate_keypair() -RECIPIENT_PRIV, RECIPIENT_PUB = _generate_keypair() -SENDER_SIGNER = _LocalSignerStub(SENDER_PRIV) -RECIPIENT_SIGNER = _LocalSignerStub(RECIPIENT_PRIV) - - -class TestCreateRumor: - def test_has_id_but_no_sig(self): - rumor = create_rumor(SENDER_PUB, "hello", kind=14) - assert rumor.id != "" - assert rumor.sig is None - - def test_kind_and_content(self): - rumor = create_rumor(SENDER_PUB, "test message", kind=14, tags=[["p", RECIPIENT_PUB]]) - assert rumor.kind == 14 - assert rumor.content == "test message" - assert rumor.pubkey == SENDER_PUB - assert ["p", RECIPIENT_PUB] in rumor.tags - - def test_custom_timestamp(self): - ts = 1700000000 - rumor = create_rumor(SENDER_PUB, "hello", created_at=ts) - assert rumor.created_at == ts - - -class TestCreateSeal: - @pytest.mark.asyncio - async def test_kind_13_with_empty_tags(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - assert seal.kind == 13 - assert seal.tags == [] - assert seal.pubkey == SENDER_PUB - - @pytest.mark.asyncio - async def test_is_signed(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - assert seal.sig is not None - assert len(seal.sig) == 128 # 64 bytes hex - - @pytest.mark.asyncio - async def test_content_is_encrypted(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - # Content should be base64 NIP-44 payload, not plaintext JSON - assert "hello" not in seal.content - - @pytest.mark.asyncio - async def test_timestamp_is_randomized(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - now = int(time.time()) - # Seal timestamp should be in the past (up to 2 days) - assert seal.created_at <= now - assert seal.created_at >= now - (2 * 24 * 60 * 60 + 10) - - -class TestCreateGiftWrap: - @pytest.mark.asyncio - async def test_kind_1059_with_p_tag(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - wrap = create_gift_wrap(seal, RECIPIENT_PUB) - assert wrap.kind == 1059 - assert ["p", RECIPIENT_PUB] in wrap.tags - - @pytest.mark.asyncio - async def test_uses_ephemeral_key(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - wrap = create_gift_wrap(seal, RECIPIENT_PUB) - # Gift wrap pubkey should be neither sender nor recipient - assert wrap.pubkey != SENDER_PUB - assert wrap.pubkey != RECIPIENT_PUB - - @pytest.mark.asyncio - async def test_different_wraps_have_different_ephemeral_keys(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - wrap1 = create_gift_wrap(seal, RECIPIENT_PUB) - wrap2 = create_gift_wrap(seal, RECIPIENT_PUB) - assert wrap1.pubkey != wrap2.pubkey - - -class TestUnwrap: - @pytest.mark.asyncio - async def test_unwrap_gift_wrap_returns_seal(self): - rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - wrap = create_gift_wrap(seal, RECIPIENT_PUB) - - recovered_seal = await unwrap_gift_wrap(wrap, RECIPIENT_SIGNER) - assert recovered_seal.kind == 13 - assert recovered_seal.pubkey == SENDER_PUB - - @pytest.mark.asyncio - async def test_unseal_returns_rumor(self): - rumor = create_rumor(SENDER_PUB, "hello world") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - - recovered_rumor = await unseal(seal, RECIPIENT_SIGNER) - assert recovered_rumor.content == "hello world" - assert recovered_rumor.pubkey == SENDER_PUB - assert recovered_rumor.kind == 14 - - @pytest.mark.asyncio - async def test_wrong_key_fails(self): - rumor = create_rumor(SENDER_PUB, "secret") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) - wrap = create_gift_wrap(seal, RECIPIENT_PUB) - - wrong_priv, _ = _generate_keypair() - wrong_signer = _LocalSignerStub(wrong_priv) - with pytest.raises(Exception): - await unwrap_message(wrap, wrong_signer) - - -class TestFullRoundTrip: - @pytest.mark.asyncio - async def test_wrap_unwrap_message(self): - content = "Are you going to the party tonight?" - wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB) - - assert wrap.kind == 1059 - assert ["p", RECIPIENT_PUB] in wrap.tags - - rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) - assert rumor.content == content - assert rumor.pubkey == SENDER_PUB - assert rumor.kind == 14 - assert rumor.sig is None - - @pytest.mark.asyncio - async def test_wrap_with_custom_kind_and_tags(self): - tags = [["p", RECIPIENT_PUB], ["subject", "test"]] - wrap = await wrap_message( - "order data", - SENDER_SIGNER, - RECIPIENT_PUB, - kind=14, - tags=tags, - ) - - rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) - assert rumor.content == "order data" - assert rumor.kind == 14 - assert ["subject", "test"] in rumor.tags - - @pytest.mark.asyncio - async def test_self_wrap_for_archival(self): - """Merchant wraps a copy to self (same sender and recipient).""" - content = '{"type": 1, "payment_options": [{"type": "ln", "link": "lnbc..."}]}' - wrap = await wrap_message(content, SENDER_SIGNER, SENDER_PUB) - - rumor = await unwrap_message(wrap, SENDER_SIGNER) - assert rumor.content == content - assert rumor.pubkey == SENDER_PUB - - @pytest.mark.asyncio - async def test_json_content_preserved(self): - """Order JSON payloads survive the wrap/unwrap cycle.""" - order = { - "type": 0, - "id": "test-order-123", - "items": [{"product_id": "abc", "quantity": 2}], - "shipping_id": "zone-1", - } - content = json.dumps(order) - wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB) - - rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) - recovered_order = json.loads(rumor.content) - assert recovered_order == order - - @pytest.mark.asyncio - async def test_unicode_content(self): - content = "Payment received! \u2705 Your order is being processed \U0001f4e6" - wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB) - rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) - assert rumor.content == content diff --git a/views_api.py b/views_api.py index 550cca1..7bf3574 100644 --- a/views_api.py +++ b/views_api.py @@ -4,7 +4,7 @@ from typing import List, Optional from fastapi import Depends from fastapi.exceptions import HTTPException -from lnbits.core.crud import get_account +from lnbits.core.crud import get_account, update_account from lnbits.core.services import websocket_updater from lnbits.decorators import ( WalletTypeInfo, @@ -12,6 +12,7 @@ from lnbits.decorators import ( 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 @@ -38,7 +39,6 @@ from .crud import ( get_last_direct_messages_time, get_merchant_by_pubkey, get_merchant_for_user, - update_merchant_pubkey, get_order, get_order_by_event_id, get_orders, @@ -82,10 +82,8 @@ from .models import ( from .services import ( build_order_with_payment, create_or_update_order_from_dm, - provision_merchant, reply_to_structured_dm, resubscribe_to_all_merchants, - send_dm, sign_and_send_to_nostr, subscribe_to_all_merchants, update_merchant_to_nostr, @@ -94,44 +92,6 @@ from .services import ( ######################################## MERCHANT ###################################### -async def _auto_create_merchant( - wallet: WalletTypeInfo, - config: MerchantConfig | None = None, -) -> Merchant: - """ - Lazy fallback: provision a merchant from the user's account keypair when - the LNbits-side eager provisioning didn't run (e.g., older accounts, or - upstream LNbits without our signup hook). - - Post-aiolabs/nostrmarket#5: the merchant identity IS the lnbits - account identity. No `private_key` is read here — signing routes - through the account's `NostrSigner` (which holds a - `RemoteBunkerSigner` in our target deployment, with the nsec - living entirely in the bunker). The only precondition is that the - account already has a `pubkey` — every post-#9 account does, since - `create_account` provisions one via the bunker on signup. - """ - account = await get_account(wallet.wallet.user) - assert account, "User account not found" - assert account.pubkey, ( - "Account has no Nostr pubkey — must be migrated to RemoteBunkerSigner " - "before a merchant can be provisioned (see aiolabs/nostrmarket#5)" - ) - - merchant = await provision_merchant( - user_id=wallet.wallet.user, - wallet_id=wallet.wallet.id, - public_key=account.pubkey, - display_name=account.username, - config=config, - ) - - await resubscribe_to_all_merchants() - await nostr_client.merchant_temp_subscription(account.pubkey) - - return merchant - - @nostrmarket_ext.post("/api/v1/merchant") async def api_create_merchant( data: CreateMerchantRequest, @@ -139,10 +99,60 @@ async def api_create_merchant( ) -> Merchant: try: + # 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" - return await _auto_create_merchant(wallet, data.config) + # 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, + 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(public_key) + + return merchant except AssertionError as ex: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -159,13 +169,12 @@ async def api_create_merchant( @nostrmarket_ext.get("/api/v1/merchant") async def api_get_merchant( wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> Merchant: +) -> Optional[Merchant]: try: merchant = await get_merchant_for_user(wallet.wallet.user) if not merchant: - # Auto-provision merchant from the user's account keypair - merchant = await _auto_create_merchant(wallet) + return None merchant = await touch_merchant(wallet.wallet.user, merchant.id) assert merchant @@ -173,11 +182,6 @@ async def api_get_merchant( assert merchant.time merchant.config.restore_in_progress = (merchant.time - last_dm_time) < 30 - # Detect keypair rotation: account key no longer matches merchant key - account = await get_account(wallet.wallet.user) - if account and account.pubkey and account.pubkey != merchant.public_key: - merchant.config.key_mismatch = True - return merchant except Exception as ex: logger.warning(ex) @@ -223,75 +227,6 @@ async def api_delete_merchant( await subscribe_to_all_merchants() -@nostrmarket_ext.post("/api/v1/merchant/{merchant_id}/migrate-keys") -async def api_migrate_merchant_keys( - merchant_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), -) -> Merchant: - """ - Migrate a merchant to the current account keypair. - - When a user rotates their Nostr keypair, the merchant still holds the old - key. This endpoint updates the merchant's keys to match the account, - then republishes all stalls and products under the new identity. - - Orders and DM history are preserved (they reference customer pubkeys, - not the merchant key). Old stall/product events on relays become - orphaned — clients following the new pubkey will see the fresh events. - """ - try: - merchant = await get_merchant_for_user(wallet.wallet.user) - assert merchant, "Merchant cannot be found" - assert merchant.id == merchant_id, "Wrong merchant ID" - - account = await get_account(wallet.wallet.user) - assert account and account.pubkey, "Account has no Nostr pubkey" - - if account.pubkey == merchant.public_key: - return merchant # already in sync - - # Check no other merchant is using the new pubkey - existing = await get_merchant_by_pubkey(account.pubkey) - assert existing is None, ( - "Another merchant already uses this public key" - ) - - old_pubkey = merchant.public_key - - # Post-aiolabs/nostrmarket#5: re-pointing only the pubkey; the - # signing nsec lives in the bunker and is keyed on account.id, - # which is unchanged. No private_key column to update. - merchant = await update_merchant_pubkey( - wallet.wallet.user, merchant.id, account.pubkey, - ) - assert merchant - - # Republish all stalls and products under the new key - merchant = await update_merchant_to_nostr(merchant) - - logger.info( - f"[NOSTRMARKET] Migrated merchant {merchant.id} " - f"from {old_pubkey[:16]}... to {account.pubkey[:16]}..." - ) - - # Resubscribe with new pubkey - await resubscribe_to_all_merchants() - - return merchant - - except AssertionError as ex: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=str(ex), - ) from ex - except Exception as ex: - logger.warning(ex) - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Cannot migrate merchant keys", - ) from ex - - @nostrmarket_ext.patch("/api/v1/merchant/{merchant_id}") async def api_update_merchant( merchant_id: str, @@ -754,21 +689,6 @@ async def api_create_product( assert stall, "Stall missing for product" data.config.currency = stall.currency - # Re-publish the parent stall before publishing the product. NIP-33 - # parameterized replaceable events make this idempotent on relays. - # This guarantees the customer client never sees a product whose - # parent stall isn't on the relay (e.g., when the original stall - # publish failed transiently or never ran). - try: - stall_event = await sign_and_send_to_nostr(merchant, stall) - stall.event_id = stall_event.id - await update_stall(merchant.id, stall) - except Exception as ex: - logger.warning( - f"[NOSTRMARKET] Failed to refresh stall {stall.id} " - f"before product publish: {ex}" - ) - product = await create_product(merchant.id, data=data) event = await sign_and_send_to_nostr(merchant, product) @@ -961,11 +881,27 @@ async def api_update_order_status( ensure_ascii=False, ) - await send_dm( - merchant, - order.public_key, - DirectMessageType.ORDER_PAID_OR_SHIPPED.value, - dm_content, + dm_event = merchant.build_dm_event(dm_content, order.public_key) + + dm = PartialDirectMessage( + event_id=dm_event.id, + event_created_at=dm_event.created_at, + message=dm_content, + public_key=order.public_key, + type=DirectMessageType.ORDER_PAID_OR_SHIPPED.value, + ) + await create_direct_message(merchant.id, dm) + + await nostr_client.publish_nostr_event(dm_event) + await websocket_updater( + merchant.id, + json.dumps( + { + "type": f"dm:{dm.type}", + "customerPubkey": order.public_key, + "dm": dm.dict(), + } + ), ) return order @@ -1143,13 +1079,14 @@ async def api_create_message( merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" - dm_reply = await send_dm( - merchant, - data.public_key, - data.type, - data.message, - ) - return dm_reply + dm_event = merchant.build_dm_event(data.message, data.public_key) + data.event_id = dm_event.id + data.event_created_at = dm_event.created_at + + dm = await create_direct_message(merchant.id, data) + await nostr_client.publish_nostr_event(dm_event) + + return dm except AssertionError as ex: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST,