Compare commits
12 commits
v1.1.0-aio
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| adda751eb0 | |||
| 774c3586a1 | |||
| 14e7ea63eb | |||
| c677e1bb7d | |||
| c859b95521 | |||
| 50f87c9970 | |||
| 05ebf042ac | |||
| e481c9179d | |||
| 3cc798aab2 | |||
| 25023df8bd | |||
| 5c38947fc6 | |||
| 725944ae9c |
19 changed files with 1468 additions and 425 deletions
17
__init__.py
17
__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])
|
||||
|
|
|
|||
35
crud.py
35
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"""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
71
helpers.py
71
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:
|
||||
|
|
|
|||
77
migrations_fork.py
Normal file
77
migrations_fork.py
Normal file
|
|
@ -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")
|
||||
41
models.py
41
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":
|
||||
|
|
|
|||
180
nostr/nip44.py
Normal file
180
nostr/nip44.py
Normal file
|
|
@ -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)
|
||||
231
nostr/nip59.py
Normal file
231
nostr/nip59.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
274
services.py
274
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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
36
tasks.py
36
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}")
|
||||
|
|
|
|||
|
|
@ -64,25 +64,6 @@
|
|||
</q-btn>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator></q-separator>
|
||||
<q-item clickable v-close-popup @click="$emit('import-key')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="vpn_key" color="primary"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Import Existing Key</q-item-label>
|
||||
<q-item-label caption>Use an existing nsec</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="$emit('generate-key')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="add" color="primary"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Generate New Key</q-item-label>
|
||||
<q-item-label caption>Create a fresh nsec</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
<q-btn
|
||||
|
|
|
|||
|
|
@ -3,6 +3,26 @@
|
|||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 col-lg-8 q-gutter-y-md">
|
||||
<div v-if="merchant && merchant.id">
|
||||
<q-banner
|
||||
v-if="merchant.config && merchant.config.key_mismatch"
|
||||
class="bg-warning text-white q-mb-md"
|
||||
rounded
|
||||
>
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="warning" color="white"></q-icon>
|
||||
</template>
|
||||
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.
|
||||
<template v-slot:action>
|
||||
<q-btn
|
||||
flat
|
||||
color="white"
|
||||
label="Migrate Keys"
|
||||
@click="migrateKeys"
|
||||
></q-btn>
|
||||
</template>
|
||||
</q-banner>
|
||||
<q-card>
|
||||
<div class="row items-center no-wrap">
|
||||
<q-tabs
|
||||
|
|
@ -82,8 +102,6 @@
|
|||
@merchant-deleted="handleMerchantDeleted"
|
||||
@toggle-merchant-state="toggleMerchantState"
|
||||
@restart-nostr-connection="restartNostrConnection"
|
||||
@import-key="showImportKeysDialog"
|
||||
@generate-key="generateKeys"
|
||||
@profile-updated="getMerchant"
|
||||
></merchant-tab>
|
||||
</q-tab-panel>
|
||||
|
|
@ -124,58 +142,9 @@
|
|||
</q-card>
|
||||
</div>
|
||||
<q-card v-else>
|
||||
<q-card-section>
|
||||
<span class="text-h4">Welcome to Nostr Market!</span><br />
|
||||
In Nostr Market, merchant and customer communicate via NOSTR relays, so
|
||||
loss of money, product information, and reputation become far less
|
||||
likely if attacked.
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<span class="text-h4">Terms</span><br />
|
||||
<ul>
|
||||
<li>
|
||||
<span class="text-bold">merchant</span> - seller of products with
|
||||
NOSTR key-pair
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-bold">customer</span> - buyer of products with
|
||||
NOSTR key-pair
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-bold">product</span> - item for sale by the
|
||||
merchant
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-bold">stall</span> - list of products controlled
|
||||
by merchant (a merchant can have multiple stalls)
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-bold">marketplace</span> - clientside software for
|
||||
searching stalls and purchasing products
|
||||
</li>
|
||||
</ul>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-btn
|
||||
@click="showImportKeysDialog"
|
||||
label="Import Key"
|
||||
color="primary"
|
||||
class="float-left"
|
||||
>
|
||||
<q-tooltip> Use an existing private key (hex or npub) </q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
label="Generate New Key"
|
||||
color="green"
|
||||
@click="generateKeys"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip> A new key pair will be generated for you </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-card-section class="text-center q-pa-xl">
|
||||
<q-spinner color="primary" size="3em" class="q-mb-md"></q-spinner>
|
||||
<div class="text-h6">Setting up Nostr Market...</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
|
@ -396,89 +365,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<q-dialog v-model="importKeyDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="importKeys" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="importKeyDialog.data.privateKey"
|
||||
label="Private Key (hex or nsec)"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="!importKeyDialog.data.privateKey"
|
||||
type="submit"
|
||||
>Import</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
|
||||
<!-- Generate Key Dialog -->
|
||||
<q-dialog v-model="generateKeyDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<div class="text-h6 q-mb-md">Generate New Key</div>
|
||||
<div class="q-mb-md">
|
||||
<div class="text-subtitle2 q-mb-xs">Public Key (npub)</div>
|
||||
<q-input :model-value="generateKeyDialog.npub" readonly dense outlined>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
icon="content_copy"
|
||||
@click="copyText(generateKeyDialog.npub, 'npub copied!')"
|
||||
></q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="q-mb-md">
|
||||
<div class="text-subtitle2 q-mb-xs text-warning">
|
||||
<q-icon name="warning" size="xs"></q-icon>
|
||||
Private Key (nsec)
|
||||
</div>
|
||||
<q-input
|
||||
:model-value="generateKeyDialog.showNsec ? generateKeyDialog.nsec : '••••••••••••••••••••••••••••••••••••••••••••••'"
|
||||
readonly
|
||||
dense
|
||||
outlined
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
:icon="generateKeyDialog.showNsec ? 'visibility_off' : 'visibility'"
|
||||
@click="generateKeyDialog.showNsec = !generateKeyDialog.showNsec"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
icon="content_copy"
|
||||
@click="copyText(generateKeyDialog.nsec, 'nsec copied! Keep it safe!')"
|
||||
></q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
<div class="text-caption text-negative q-mt-xs">
|
||||
<q-icon name="error" size="xs"></q-icon>
|
||||
Never share your private key!
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" @click="confirmGenerateKey"
|
||||
>Create Merchant</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock%}{% block scripts %} {{ window_vars(user) }}
|
||||
|
||||
|
|
|
|||
27
tests/conftest.py
Normal file
27
tests/conftest.py
Normal file
|
|
@ -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
|
||||
139
tests/test_nip44.py
Normal file
139
tests/test_nip44.py
Normal file
|
|
@ -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))
|
||||
258
tests/test_nip59.py
Normal file
258
tests/test_nip59.py
Normal file
|
|
@ -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
|
||||
231
views_api.py
231
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue