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