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