Compare commits
No commits in common. "1b39744daa8eec5ddf26f7c6cdc232998e58fdf8" and "7771201c329b6dff463d6769b22169308be4dd16" have entirely different histories.
1b39744daa
...
7771201c32
16 changed files with 341 additions and 1441 deletions
17
__init__.py
17
__init__.py
|
|
@ -27,11 +27,7 @@ def nostrmarket_renderer():
|
||||||
nostr_client: NostrClient = NostrClient()
|
nostr_client: NostrClient = NostrClient()
|
||||||
|
|
||||||
|
|
||||||
from .tasks import ( # noqa
|
from .tasks import wait_for_nostr_events, wait_for_paid_invoices # noqa
|
||||||
subscription_health_monitor,
|
|
||||||
wait_for_nostr_events,
|
|
||||||
wait_for_paid_invoices,
|
|
||||||
)
|
|
||||||
from .views import * # noqa
|
from .views import * # noqa
|
||||||
from .views_api import * # noqa
|
from .views_api import * # noqa
|
||||||
|
|
||||||
|
|
@ -69,13 +65,4 @@ def nostrmarket_start():
|
||||||
task3 = create_permanent_unique_task(
|
task3 = create_permanent_unique_task(
|
||||||
"ext_nostrmarket_wait_for_events", _wait_for_nostr_events
|
"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])
|
|
||||||
|
|
|
||||||
69
crud.py
69
crud.py
|
|
@ -1,5 +1,4 @@
|
||||||
import json
|
import json
|
||||||
from typing import List, Optional, Tuple
|
|
||||||
|
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
|
|
@ -44,7 +43,7 @@ async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant:
|
||||||
|
|
||||||
async def update_merchant(
|
async def update_merchant(
|
||||||
user_id: str, merchant_id: str, config: MerchantConfig
|
user_id: str, merchant_id: str, config: MerchantConfig
|
||||||
) -> Optional[Merchant]:
|
) -> Merchant | None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"""
|
f"""
|
||||||
UPDATE nostrmarket.merchants SET meta = :meta, time = {db.timestamp_now}
|
UPDATE nostrmarket.merchants SET meta = :meta, time = {db.timestamp_now}
|
||||||
|
|
@ -55,7 +54,7 @@ async def update_merchant(
|
||||||
return await get_merchant(user_id, merchant_id)
|
return await get_merchant(user_id, merchant_id)
|
||||||
|
|
||||||
|
|
||||||
async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
|
async def touch_merchant(user_id: str, merchant_id: str) -> Merchant | None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"""
|
f"""
|
||||||
UPDATE nostrmarket.merchants SET time = {db.timestamp_now}
|
UPDATE nostrmarket.merchants SET time = {db.timestamp_now}
|
||||||
|
|
@ -66,7 +65,7 @@ async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
|
||||||
return await get_merchant(user_id, merchant_id)
|
return await get_merchant(user_id, merchant_id)
|
||||||
|
|
||||||
|
|
||||||
async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
|
async def get_merchant(user_id: str, merchant_id: str) -> Merchant | None:
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id AND id = :id""",
|
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id AND id = :id""",
|
||||||
{
|
{
|
||||||
|
|
@ -78,7 +77,7 @@ async def get_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
|
||||||
return Merchant.from_row(row) if row else None
|
return Merchant.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]:
|
async def get_merchant_by_pubkey(public_key: str) -> Merchant | None:
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"""SELECT * FROM nostrmarket.merchants WHERE public_key = :public_key""",
|
"""SELECT * FROM nostrmarket.merchants WHERE public_key = :public_key""",
|
||||||
{"public_key": public_key},
|
{"public_key": public_key},
|
||||||
|
|
@ -87,7 +86,7 @@ async def get_merchant_by_pubkey(public_key: str) -> Optional[Merchant]:
|
||||||
return Merchant.from_row(row) if row else None
|
return Merchant.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def get_merchants_ids_with_pubkeys() -> List[Tuple[str, str]]:
|
async def get_merchants_ids_with_pubkeys() -> list[tuple[str, str]]:
|
||||||
rows: list[dict] = await db.fetchall(
|
rows: list[dict] = await db.fetchall(
|
||||||
"""SELECT id, public_key FROM nostrmarket.merchants""",
|
"""SELECT id, public_key FROM nostrmarket.merchants""",
|
||||||
)
|
)
|
||||||
|
|
@ -95,7 +94,7 @@ async def get_merchants_ids_with_pubkeys() -> List[Tuple[str, str]]:
|
||||||
return [(row["id"], row["public_key"]) for row in rows]
|
return [(row["id"], row["public_key"]) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def get_merchant_for_user(user_id: str) -> Optional[Merchant]:
|
async def get_merchant_for_user(user_id: str) -> Merchant | None:
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id """,
|
"""SELECT * FROM nostrmarket.merchants WHERE user_id = :user_id """,
|
||||||
{"user_id": user_id},
|
{"user_id": user_id},
|
||||||
|
|
@ -138,7 +137,7 @@ async def create_zone(merchant_id: str, data: Zone) -> Zone:
|
||||||
return zone
|
return zone
|
||||||
|
|
||||||
|
|
||||||
async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]:
|
async def update_zone(merchant_id: str, z: Zone) -> Zone | None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE nostrmarket.zones
|
UPDATE nostrmarket.zones
|
||||||
|
|
@ -157,7 +156,7 @@ async def update_zone(merchant_id: str, z: Zone) -> Optional[Zone]:
|
||||||
return await get_zone(merchant_id, z.id)
|
return await get_zone(merchant_id, z.id)
|
||||||
|
|
||||||
|
|
||||||
async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]:
|
async def get_zone(merchant_id: str, zone_id: str) -> Zone | None:
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id AND id = :id",
|
"SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id AND id = :id",
|
||||||
{
|
{
|
||||||
|
|
@ -168,7 +167,7 @@ async def get_zone(merchant_id: str, zone_id: str) -> Optional[Zone]:
|
||||||
return Zone.from_row(row) if row else None
|
return Zone.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def get_zones(merchant_id: str) -> List[Zone]:
|
async def get_zones(merchant_id: str) -> list[Zone]:
|
||||||
rows: list[dict] = await db.fetchall(
|
rows: list[dict] = await db.fetchall(
|
||||||
"SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id",
|
"SELECT * FROM nostrmarket.zones WHERE merchant_id = :merchant_id",
|
||||||
{"merchant_id": merchant_id},
|
{"merchant_id": merchant_id},
|
||||||
|
|
@ -235,7 +234,7 @@ async def create_stall(merchant_id: str, data: Stall) -> Stall:
|
||||||
return stall
|
return stall
|
||||||
|
|
||||||
|
|
||||||
async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]:
|
async def get_stall(merchant_id: str, stall_id: str) -> Stall | None:
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM nostrmarket.stalls
|
SELECT * FROM nostrmarket.stalls
|
||||||
|
|
@ -249,7 +248,7 @@ async def get_stall(merchant_id: str, stall_id: str) -> Optional[Stall]:
|
||||||
return Stall.from_row(row) if row else None
|
return Stall.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def get_stalls(merchant_id: str, pending: Optional[bool] = False) -> List[Stall]:
|
async def get_stalls(merchant_id: str, pending: bool | None = False) -> list[Stall]:
|
||||||
rows: list[dict] = await db.fetchall(
|
rows: list[dict] = await db.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM nostrmarket.stalls
|
SELECT * FROM nostrmarket.stalls
|
||||||
|
|
@ -274,7 +273,7 @@ async def get_last_stall_update_time() -> int:
|
||||||
return row["event_created_at"] or 0 if row else 0
|
return row["event_created_at"] or 0 if row else 0
|
||||||
|
|
||||||
|
|
||||||
async def update_stall(merchant_id: str, stall: Stall) -> Optional[Stall]:
|
async def update_stall(merchant_id: str, stall: Stall) -> Stall | None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE nostrmarket.stalls
|
UPDATE nostrmarket.stalls
|
||||||
|
|
@ -398,9 +397,7 @@ async def update_product(merchant_id: str, product: Product) -> Product:
|
||||||
return updated_product
|
return updated_product
|
||||||
|
|
||||||
|
|
||||||
async def update_product_quantity(
|
async def update_product_quantity(product_id: str, new_quantity: int) -> Product | None:
|
||||||
product_id: str, new_quantity: int
|
|
||||||
) -> Optional[Product]:
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE nostrmarket.products SET quantity = :quantity
|
UPDATE nostrmarket.products SET quantity = :quantity
|
||||||
|
|
@ -415,7 +412,7 @@ async def update_product_quantity(
|
||||||
return Product.from_row(row) if row else None
|
return Product.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def get_product(merchant_id: str, product_id: str) -> Optional[Product]:
|
async def get_product(merchant_id: str, product_id: str) -> Product | None:
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM nostrmarket.products
|
SELECT * FROM nostrmarket.products
|
||||||
|
|
@ -431,8 +428,8 @@ async def get_product(merchant_id: str, product_id: str) -> Optional[Product]:
|
||||||
|
|
||||||
|
|
||||||
async def get_products(
|
async def get_products(
|
||||||
merchant_id: str, stall_id: str, pending: Optional[bool] = False
|
merchant_id: str, stall_id: str, pending: bool | None = False
|
||||||
) -> List[Product]:
|
) -> list[Product]:
|
||||||
rows: list[dict] = await db.fetchall(
|
rows: list[dict] = await db.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM nostrmarket.products
|
SELECT * FROM nostrmarket.products
|
||||||
|
|
@ -445,8 +442,8 @@ async def get_products(
|
||||||
|
|
||||||
|
|
||||||
async def get_products_by_ids(
|
async def get_products_by_ids(
|
||||||
merchant_id: str, product_ids: List[str]
|
merchant_id: str, product_ids: list[str]
|
||||||
) -> List[Product]:
|
) -> list[Product]:
|
||||||
# todo: revisit
|
# todo: revisit
|
||||||
|
|
||||||
keys = []
|
keys = []
|
||||||
|
|
@ -467,7 +464,7 @@ async def get_products_by_ids(
|
||||||
return [Product.from_row(row) for row in rows]
|
return [Product.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def get_wallet_for_product(product_id: str) -> Optional[str]:
|
async def get_wallet_for_product(product_id: str) -> str | None:
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT s.wallet as wallet FROM nostrmarket.products p
|
SELECT s.wallet as wallet FROM nostrmarket.products p
|
||||||
|
|
@ -574,7 +571,7 @@ async def create_order(merchant_id: str, o: Order) -> Order:
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
async def get_order(merchant_id: str, order_id: str) -> Optional[Order]:
|
async def get_order(merchant_id: str, order_id: str) -> Order | None:
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM nostrmarket.orders
|
SELECT * FROM nostrmarket.orders
|
||||||
|
|
@ -588,7 +585,7 @@ async def get_order(merchant_id: str, order_id: str) -> Optional[Order]:
|
||||||
return Order.from_row(row) if row else None
|
return Order.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Order]:
|
async def get_order_by_event_id(merchant_id: str, event_id: str) -> Order | None:
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM nostrmarket.orders
|
SELECT * FROM nostrmarket.orders
|
||||||
|
|
@ -602,7 +599,7 @@ async def get_order_by_event_id(merchant_id: str, event_id: str) -> Optional[Ord
|
||||||
return Order.from_row(row) if row else None
|
return Order.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def get_orders(merchant_id: str, **kwargs) -> List[Order]:
|
async def get_orders(merchant_id: str, **kwargs) -> list[Order]:
|
||||||
q = " AND ".join(
|
q = " AND ".join(
|
||||||
[
|
[
|
||||||
f"{field[0]} = :{field[0]}"
|
f"{field[0]} = :{field[0]}"
|
||||||
|
|
@ -629,7 +626,7 @@ async def get_orders(merchant_id: str, **kwargs) -> List[Order]:
|
||||||
|
|
||||||
async def get_orders_for_stall(
|
async def get_orders_for_stall(
|
||||||
merchant_id: str, stall_id: str, **kwargs
|
merchant_id: str, stall_id: str, **kwargs
|
||||||
) -> List[Order]:
|
) -> list[Order]:
|
||||||
q = " AND ".join(
|
q = " AND ".join(
|
||||||
[
|
[
|
||||||
f"{field[0]} = :{field[0]}"
|
f"{field[0]} = :{field[0]}"
|
||||||
|
|
@ -655,7 +652,7 @@ async def get_orders_for_stall(
|
||||||
return [Order.from_row(row) for row in rows]
|
return [Order.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Order]:
|
async def update_order(merchant_id: str, order_id: str, **kwargs) -> Order | None:
|
||||||
q = ", ".join(
|
q = ", ".join(
|
||||||
[
|
[
|
||||||
f"{field[0]} = :{field[0]}"
|
f"{field[0]} = :{field[0]}"
|
||||||
|
|
@ -679,7 +676,7 @@ async def update_order(merchant_id: str, order_id: str, **kwargs) -> Optional[Or
|
||||||
return await get_order(merchant_id, order_id)
|
return await get_order(merchant_id, order_id)
|
||||||
|
|
||||||
|
|
||||||
async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]:
|
async def update_order_paid_status(order_id: str, paid: bool) -> Order | None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id",
|
"UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id",
|
||||||
{"paid": paid, "id": order_id},
|
{"paid": paid, "id": order_id},
|
||||||
|
|
@ -693,7 +690,7 @@ async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]
|
||||||
|
|
||||||
async def update_order_shipped_status(
|
async def update_order_shipped_status(
|
||||||
merchant_id: str, order_id: str, shipped: bool
|
merchant_id: str, order_id: str, shipped: bool
|
||||||
) -> Optional[Order]:
|
) -> Order | None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE nostrmarket.orders
|
UPDATE nostrmarket.orders
|
||||||
|
|
@ -757,7 +754,7 @@ async def create_direct_message(
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMessage]:
|
async def get_direct_message(merchant_id: str, dm_id: str) -> DirectMessage | None:
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM nostrmarket.direct_messages
|
SELECT * FROM nostrmarket.direct_messages
|
||||||
|
|
@ -773,7 +770,7 @@ async def get_direct_message(merchant_id: str, dm_id: str) -> Optional[DirectMes
|
||||||
|
|
||||||
async def get_direct_message_by_event_id(
|
async def get_direct_message_by_event_id(
|
||||||
merchant_id: str, event_id: str
|
merchant_id: str, event_id: str
|
||||||
) -> Optional[DirectMessage]:
|
) -> DirectMessage | None:
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM nostrmarket.direct_messages
|
SELECT * FROM nostrmarket.direct_messages
|
||||||
|
|
@ -787,7 +784,7 @@ async def get_direct_message_by_event_id(
|
||||||
return DirectMessage.from_row(row) if row else None
|
return DirectMessage.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def get_direct_messages(merchant_id: str, public_key: str) -> List[DirectMessage]:
|
async def get_direct_messages(merchant_id: str, public_key: str) -> list[DirectMessage]:
|
||||||
rows: list[dict] = await db.fetchall(
|
rows: list[dict] = await db.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM nostrmarket.direct_messages
|
SELECT * FROM nostrmarket.direct_messages
|
||||||
|
|
@ -799,7 +796,7 @@ async def get_direct_messages(merchant_id: str, public_key: str) -> List[DirectM
|
||||||
return [DirectMessage.from_row(row) for row in rows]
|
return [DirectMessage.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def get_orders_from_direct_messages(merchant_id: str) -> List[DirectMessage]:
|
async def get_orders_from_direct_messages(merchant_id: str) -> list[DirectMessage]:
|
||||||
rows: list[dict] = await db.fetchall(
|
rows: list[dict] = await db.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM nostrmarket.direct_messages
|
SELECT * FROM nostrmarket.direct_messages
|
||||||
|
|
@ -860,7 +857,7 @@ async def create_customer(merchant_id: str, data: Customer) -> Customer:
|
||||||
return customer
|
return customer
|
||||||
|
|
||||||
|
|
||||||
async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]:
|
async def get_customer(merchant_id: str, public_key: str) -> Customer | None:
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM nostrmarket.customers
|
SELECT * FROM nostrmarket.customers
|
||||||
|
|
@ -874,7 +871,7 @@ async def get_customer(merchant_id: str, public_key: str) -> Optional[Customer]:
|
||||||
return Customer.from_row(row) if row else None
|
return Customer.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def get_customers(merchant_id: str) -> List[Customer]:
|
async def get_customers(merchant_id: str) -> list[Customer]:
|
||||||
rows: list[dict] = await db.fetchall(
|
rows: list[dict] = await db.fetchall(
|
||||||
"SELECT * FROM nostrmarket.customers WHERE merchant_id = :merchant_id",
|
"SELECT * FROM nostrmarket.customers WHERE merchant_id = :merchant_id",
|
||||||
{"merchant_id": merchant_id},
|
{"merchant_id": merchant_id},
|
||||||
|
|
@ -882,7 +879,7 @@ async def get_customers(merchant_id: str) -> List[Customer]:
|
||||||
return [Customer.from_row(row) for row in rows]
|
return [Customer.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def get_all_unique_customers() -> List[Customer]:
|
async def get_all_unique_customers() -> list[Customer]:
|
||||||
q = """
|
q = """
|
||||||
SELECT public_key, MAX(merchant_id) as merchant_id, MAX(event_created_at)
|
SELECT public_key, MAX(merchant_id) as merchant_id, MAX(event_created_at)
|
||||||
FROM nostrmarket.customers
|
FROM nostrmarket.customers
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,4 @@ The Nostr Market extension includes:
|
||||||
- A merchant client to manage products, sales and communication with customers.
|
- A merchant client to manage products, sales and communication with customers.
|
||||||
- A customer client to find and order products from merchants, communicate with merchants and track status of ordered products.
|
- A customer client to find and order products from merchants, communicate with merchants and track status of ordered products.
|
||||||
|
|
||||||
All communication happens over NIP-17 private direct messages (NIP-44 encryption + NIP-59 gift wrapping).
|
All communication happens over NIP04 encrypted DMs.
|
||||||
|
|
|
||||||
60
helpers.py
60
helpers.py
|
|
@ -1,5 +1,54 @@
|
||||||
|
import base64
|
||||||
|
import secrets
|
||||||
|
|
||||||
import coincurve
|
import coincurve
|
||||||
from bech32 import bech32_decode, convertbits
|
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: bytes | None = 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:
|
def sign_message_hash(private_key: str, hash_: bytes) -> str:
|
||||||
|
|
@ -8,6 +57,17 @@ def sign_message_hash(private_key: str, hash_: bytes) -> str:
|
||||||
return sig.hex()
|
return sig.hex()
|
||||||
|
|
||||||
|
|
||||||
|
def test_decrypt_encrypt(encoded_message: str, encryption_key):
|
||||||
|
msg = decrypt_message(encoded_message, encryption_key)
|
||||||
|
|
||||||
|
# ecrypt using the same initialisation vector
|
||||||
|
iv = base64.b64decode(encoded_message.split("?iv=")[1])
|
||||||
|
ecrypted_msg = encrypt_message(msg, encryption_key, iv)
|
||||||
|
assert (
|
||||||
|
encoded_message == ecrypted_msg
|
||||||
|
), f"expected '{encoded_message}', but got '{ecrypted_msg}'"
|
||||||
|
|
||||||
|
|
||||||
def normalize_public_key(pubkey: str) -> str:
|
def normalize_public_key(pubkey: str) -> str:
|
||||||
if pubkey.startswith("npub1"):
|
if pubkey.startswith("npub1"):
|
||||||
_, decoded_data = bech32_decode(pubkey)
|
_, decoded_data = bech32_decode(pubkey)
|
||||||
|
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
# Nostrmarket Order Discovery Analysis
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
This document analyzes the order discovery mechanism in the Nostrmarket extension and identifies why merchants must manually refresh to see new orders instead of receiving them automatically through persistent subscriptions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Architecture
|
|
||||||
|
|
||||||
### Two Subscription Systems
|
|
||||||
|
|
||||||
The Nostrmarket extension implements two distinct subscription mechanisms for receiving Nostr events:
|
|
||||||
|
|
||||||
#### 1. **Persistent Subscriptions (Background Task)**
|
|
||||||
|
|
||||||
**Purpose**: Continuous monitoring for new orders, products, and merchant events
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
|
|
||||||
- Runs via `wait_for_nostr_events()` background task
|
|
||||||
- Initiated on extension startup (15-second delay)
|
|
||||||
- Creates subscription ID: `nostrmarket-{hash}`
|
|
||||||
- Monitors all merchant public keys continuously
|
|
||||||
|
|
||||||
**Code Location**: `/nostrmarket/tasks.py:37-49`
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def wait_for_nostr_events(nostr_client: NostrClient):
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await subscribe_to_all_merchants()
|
|
||||||
while True:
|
|
||||||
message = await nostr_client.get_event()
|
|
||||||
await process_nostr_message(message)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Subscription Filters**:
|
|
||||||
|
|
||||||
- Direct messages (kind 4) - for orders
|
|
||||||
- Stall events (kind 30017)
|
|
||||||
- Product events (kind 30018)
|
|
||||||
- Profile updates (kind 0)
|
|
||||||
|
|
||||||
#### 2. **Temporary Subscriptions (Manual Refresh)**
|
|
||||||
|
|
||||||
**Purpose**: Catch up on missed events when merchant clicks "Refresh from Nostr"
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
|
|
||||||
- Duration: 10 seconds only
|
|
||||||
- Triggered by user action
|
|
||||||
- Creates subscription ID: `merchant-{hash}`
|
|
||||||
- Fetches ALL events from time=0
|
|
||||||
|
|
||||||
**Code Location**: `/nostrmarket/nostr/nostr_client.py:100-120`
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def merchant_temp_subscription(self, pk, duration=10):
|
|
||||||
dm_filters = self._filters_for_direct_messages([pk], 0)
|
|
||||||
# ... creates filters with time=0 (all history)
|
|
||||||
await self.send_req_queue.put(["REQ", subscription_id] + merchant_filters)
|
|
||||||
asyncio.create_task(unsubscribe_with_delay(subscription_id, duration))
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem Identification
|
|
||||||
|
|
||||||
### Why Manual Refresh is Required
|
|
||||||
|
|
||||||
#### **Issue 1: Timing Window Problem**
|
|
||||||
|
|
||||||
The persistent subscription uses timestamps from the last database update:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def subscribe_to_all_merchants():
|
|
||||||
last_dm_time = await get_last_direct_messages_created_at()
|
|
||||||
last_stall_time = await get_last_stall_update_time()
|
|
||||||
last_prod_time = await get_last_product_update_time()
|
|
||||||
|
|
||||||
await nostr_client.subscribe_merchants(
|
|
||||||
public_keys, last_dm_time, last_stall_time, last_prod_time, 0
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problem**: Events that occur between:
|
|
||||||
|
|
||||||
- The last database update time
|
|
||||||
- When the subscription becomes active
|
|
||||||
...are potentially missed
|
|
||||||
|
|
||||||
#### **Issue 2: Connection Stability**
|
|
||||||
|
|
||||||
The WebSocket connection between components may be unstable:
|
|
||||||
|
|
||||||
```
|
|
||||||
[Nostrmarket] <--WebSocket--> [Nostrclient] <--WebSocket--> [Nostr Relays]
|
|
||||||
Extension Extension (Global)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Potential failure points**:
|
|
||||||
|
|
||||||
1. Connection drops between nostrmarket → nostrclient
|
|
||||||
2. Connection drops between nostrclient → relays
|
|
||||||
3. Reconnection doesn't re-establish subscriptions
|
|
||||||
|
|
||||||
#### **Issue 3: Subscription State Management**
|
|
||||||
|
|
||||||
**Current behavior**:
|
|
||||||
|
|
||||||
- Single persistent subscription per merchant
|
|
||||||
- No automatic resubscription on failure
|
|
||||||
- No heartbeat/keepalive mechanism
|
|
||||||
- No verification that subscription is active
|
|
||||||
|
|
||||||
#### **Issue 4: Event Processing Delays**
|
|
||||||
|
|
||||||
The startup sequence has intentional delays:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def _subscribe_to_nostr_client():
|
|
||||||
await asyncio.sleep(10) # Wait for nostrclient
|
|
||||||
await nostr_client.run_forever()
|
|
||||||
|
|
||||||
async def _wait_for_nostr_events():
|
|
||||||
await asyncio.sleep(15) # Wait for extension init
|
|
||||||
await wait_for_nostr_events(nostr_client)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problem**: Orders arriving during initialization are missed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why Manual Refresh Works
|
|
||||||
|
|
||||||
The temporary subscription succeeds because:
|
|
||||||
|
|
||||||
1. **Fetches from time=0**: Gets ALL historical events
|
|
||||||
2. **Fresh connection**: Creates new subscription request
|
|
||||||
3. **Immediate processing**: No startup delays
|
|
||||||
4. **Direct feedback**: User sees results immediately
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Temporary subscription uses time=0 (all events)
|
|
||||||
dm_filters = self._filters_for_direct_messages([pk], 0) # ← 0 means all time
|
|
||||||
|
|
||||||
# Persistent subscription uses last update time
|
|
||||||
dm_filters = self._filters_for_direct_messages(public_keys, dm_time) # ← can miss events
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Impact Analysis
|
|
||||||
|
|
||||||
### User Experience Issues
|
|
||||||
|
|
||||||
1. **Merchants miss orders** without manual refresh
|
|
||||||
2. **No real-time notifications** for new orders
|
|
||||||
3. **Uncertainty** about order status
|
|
||||||
4. **Extra manual steps** required
|
|
||||||
5. **Delayed order fulfillment**
|
|
||||||
|
|
||||||
### Technical Implications
|
|
||||||
|
|
||||||
1. **Not truly decentralized** - requires active monitoring
|
|
||||||
2. **Scalability concerns** - manual refresh doesn't scale
|
|
||||||
3. **Reliability issues** - depends on user action
|
|
||||||
4. **Performance overhead** - fetching all events repeatedly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommended Solutions
|
|
||||||
|
|
||||||
### Solution A: Enhanced Persistent Subscriptions
|
|
||||||
|
|
||||||
**Implement redundant subscription mechanisms:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
class EnhancedSubscriptionManager:
|
|
||||||
def __init__(self):
|
|
||||||
self.last_heartbeat = time.time()
|
|
||||||
self.subscription_active = False
|
|
||||||
|
|
||||||
async def maintain_subscription(self):
|
|
||||||
while True:
|
|
||||||
if not self.subscription_active or \
|
|
||||||
time.time() - self.last_heartbeat > 30:
|
|
||||||
await self.resubscribe_with_overlap()
|
|
||||||
await asyncio.sleep(10)
|
|
||||||
|
|
||||||
async def resubscribe_with_overlap(self):
|
|
||||||
# Use timestamp with 5-minute overlap
|
|
||||||
overlap_time = int(time.time()) - 300
|
|
||||||
await subscribe_to_all_merchants(since=overlap_time)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Solution B: Periodic Auto-Refresh
|
|
||||||
|
|
||||||
**Add automatic temporary subscriptions:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def auto_refresh_loop():
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(60) # Every minute
|
|
||||||
merchants = await get_all_active_merchants()
|
|
||||||
for merchant in merchants:
|
|
||||||
await merchant_temp_subscription(merchant.pubkey, duration=5)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Solution C: WebSocket Health Monitoring
|
|
||||||
|
|
||||||
**Implement connection health checks:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
class WebSocketHealthMonitor:
|
|
||||||
async def check_connection_health(self):
|
|
||||||
try:
|
|
||||||
# Send ping to nostrclient
|
|
||||||
response = await nostr_client.ping(timeout=5)
|
|
||||||
if not response:
|
|
||||||
await self.reconnect_and_resubscribe()
|
|
||||||
except Exception:
|
|
||||||
await self.reconnect_and_resubscribe()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Solution D: Event Gap Detection
|
|
||||||
|
|
||||||
**Detect and fill gaps in event sequence:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def detect_event_gaps():
|
|
||||||
# Check for gaps in event timestamps
|
|
||||||
last_known = await get_last_event_time()
|
|
||||||
current_time = int(time.time())
|
|
||||||
|
|
||||||
if current_time - last_known > 60: # 1 minute gap
|
|
||||||
# Perform temporary subscription to fill gap
|
|
||||||
await fetch_missing_events(since=last_known)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Priority
|
|
||||||
|
|
||||||
### Phase 1: Quick Fixes (1-2 days)
|
|
||||||
|
|
||||||
1. [DONE] Increase temp subscription duration (10s → 30s)
|
|
||||||
2. [DONE] Add connection health logging
|
|
||||||
3. [DONE] Reduce startup delays
|
|
||||||
|
|
||||||
### Phase 2: Reliability (3-5 days)
|
|
||||||
|
|
||||||
1. [TODO] Implement subscription heartbeat
|
|
||||||
2. [TODO] Add automatic resubscription on failure
|
|
||||||
3. [TODO] Create event gap detection
|
|
||||||
|
|
||||||
### Phase 3: Full Solution (1-2 weeks)
|
|
||||||
|
|
||||||
1. [TODO] WebSocket connection monitoring
|
|
||||||
2. [TODO] Redundant subscription system
|
|
||||||
3. [TODO] Real-time order notifications
|
|
||||||
4. [TODO] Event deduplication logic
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Recommendations
|
|
||||||
|
|
||||||
### Test Scenarios
|
|
||||||
|
|
||||||
1. **Order during startup**: Send order within 15 seconds of server start
|
|
||||||
2. **Long-running test**: Keep server running for 24 hours, send periodic orders
|
|
||||||
3. **Connection interruption**: Disconnect nostrclient, send order, reconnect
|
|
||||||
4. **High volume**: Send 100 orders rapidly
|
|
||||||
5. **Network latency**: Add artificial delay between components
|
|
||||||
|
|
||||||
### Monitoring Metrics
|
|
||||||
|
|
||||||
- Time between order sent → order discovered
|
|
||||||
- Percentage of orders requiring manual refresh
|
|
||||||
- WebSocket connection uptime
|
|
||||||
- Subscription success rate
|
|
||||||
- Event processing latency
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The current order discovery system relies on manual refresh due to:
|
|
||||||
|
|
||||||
1. **Timing gaps** in persistent subscriptions
|
|
||||||
2. **Connection stability** issues
|
|
||||||
3. **Lack of redundancy** in subscription management
|
|
||||||
4. **No automatic recovery** mechanisms
|
|
||||||
|
|
||||||
While the temporary subscription (manual refresh) provides a workaround, a proper solution requires implementing connection monitoring, subscription health checks, and automatic gap-filling mechanisms to ensure reliable real-time order discovery.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix: Code References
|
|
||||||
|
|
||||||
### Key Files
|
|
||||||
|
|
||||||
- `/nostrmarket/tasks.py` - Background task management
|
|
||||||
- `/nostrmarket/nostr/nostr_client.py` - Nostr client implementation
|
|
||||||
- `/nostrmarket/services.py` - Order processing logic
|
|
||||||
- `/nostrmarket/views_api.py` - API endpoints for refresh
|
|
||||||
|
|
||||||
### Relevant Functions
|
|
||||||
|
|
||||||
- `wait_for_nostr_events()` - Main event loop
|
|
||||||
- `subscribe_to_all_merchants()` - Persistent subscription
|
|
||||||
- `merchant_temp_subscription()` - Manual refresh
|
|
||||||
- `process_nostr_message()` - Event processing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_Document prepared: January 2025_
|
|
||||||
_Analysis based on: Nostrmarket v1.0_
|
|
||||||
_Status: Active Investigation_
|
|
||||||
160
models.py
160
models.py
|
|
@ -2,12 +2,17 @@ import json
|
||||||
import time
|
import time
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, List, Optional, Tuple
|
from typing import Any
|
||||||
|
|
||||||
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
|
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .helpers import sign_message_hash
|
from .helpers import (
|
||||||
|
decrypt_message,
|
||||||
|
encrypt_message,
|
||||||
|
get_shared_secret,
|
||||||
|
sign_message_hash,
|
||||||
|
)
|
||||||
from .nostr.event import NostrEvent
|
from .nostr.event import NostrEvent
|
||||||
|
|
||||||
######################################## NOSTR ########################################
|
######################################## NOSTR ########################################
|
||||||
|
|
@ -27,26 +32,21 @@ class Nostrable:
|
||||||
|
|
||||||
|
|
||||||
class MerchantProfile(BaseModel):
|
class MerchantProfile(BaseModel):
|
||||||
name: Optional[str] = None
|
name: str | None = None
|
||||||
display_name: Optional[str] = None
|
display_name: str | None = None
|
||||||
about: Optional[str] = None
|
about: str | None = None
|
||||||
picture: Optional[str] = None
|
picture: str | None = None
|
||||||
banner: Optional[str] = None
|
banner: str | None = None
|
||||||
website: Optional[str] = None
|
website: str | None = None
|
||||||
nip05: Optional[str] = None
|
nip05: str | None = None
|
||||||
lud16: Optional[str] = None
|
lud16: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class MerchantConfig(MerchantProfile):
|
class MerchantConfig(MerchantProfile):
|
||||||
event_id: Optional[str] = None
|
event_id: str | None = None
|
||||||
sync_from_nostr: bool = False
|
sync_from_nostr: bool = False
|
||||||
# TODO: switched to True for AIO demo; determine if we leave this as True
|
active: bool = False
|
||||||
active: bool = True
|
restore_in_progress: bool | None = False
|
||||||
restore_in_progress: Optional[bool] = False
|
|
||||||
|
|
||||||
|
|
||||||
class CreateMerchantRequest(BaseModel):
|
|
||||||
config: MerchantConfig = MerchantConfig()
|
|
||||||
|
|
||||||
|
|
||||||
class PartialMerchant(BaseModel):
|
class PartialMerchant(BaseModel):
|
||||||
|
|
@ -57,11 +57,33 @@ class PartialMerchant(BaseModel):
|
||||||
|
|
||||||
class Merchant(PartialMerchant, Nostrable):
|
class Merchant(PartialMerchant, Nostrable):
|
||||||
id: str
|
id: str
|
||||||
time: Optional[int] = 0
|
time: int | None = 0
|
||||||
|
|
||||||
def sign_hash(self, hash_: bytes) -> str:
|
def sign_hash(self, hash_: bytes) -> str:
|
||||||
return sign_message_hash(self.private_key, hash_)
|
return sign_message_hash(self.private_key, hash_)
|
||||||
|
|
||||||
|
def decrypt_message(self, encrypted_message: str, public_key: str) -> str:
|
||||||
|
encryption_key = get_shared_secret(self.private_key, public_key)
|
||||||
|
return decrypt_message(encrypted_message, encryption_key)
|
||||||
|
|
||||||
|
def encrypt_message(self, clear_text_message: str, public_key: str) -> str:
|
||||||
|
encryption_key = get_shared_secret(self.private_key, public_key)
|
||||||
|
return encrypt_message(clear_text_message, encryption_key)
|
||||||
|
|
||||||
|
def build_dm_event(self, message: str, to_pubkey: str) -> NostrEvent:
|
||||||
|
content = self.encrypt_message(message, to_pubkey)
|
||||||
|
event = NostrEvent(
|
||||||
|
pubkey=self.public_key,
|
||||||
|
created_at=round(time.time()),
|
||||||
|
kind=4,
|
||||||
|
tags=[["p", to_pubkey]],
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
event.id = event.event_id
|
||||||
|
event.sig = self.sign_hash(bytes.fromhex(event.id))
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: dict) -> "Merchant":
|
def from_row(cls, row: dict) -> "Merchant":
|
||||||
merchant = cls(**row)
|
merchant = cls(**row)
|
||||||
|
|
@ -117,11 +139,11 @@ class Merchant(PartialMerchant, Nostrable):
|
||||||
|
|
||||||
######################################## ZONES ########################################
|
######################################## ZONES ########################################
|
||||||
class Zone(BaseModel):
|
class Zone(BaseModel):
|
||||||
id: Optional[str] = None
|
id: str | None = None
|
||||||
name: Optional[str] = None
|
name: str | None = None
|
||||||
currency: str
|
currency: str
|
||||||
cost: float
|
cost: float
|
||||||
countries: List[str] = []
|
countries: list[str] = []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: dict) -> "Zone":
|
def from_row(cls, row: dict) -> "Zone":
|
||||||
|
|
@ -134,22 +156,22 @@ class Zone(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class StallConfig(BaseModel):
|
class StallConfig(BaseModel):
|
||||||
image_url: Optional[str] = None
|
image_url: str | None = None
|
||||||
description: Optional[str] = None
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class Stall(BaseModel, Nostrable):
|
class Stall(BaseModel, Nostrable):
|
||||||
id: Optional[str] = None
|
id: str | None = None
|
||||||
wallet: str
|
wallet: str
|
||||||
name: str
|
name: str
|
||||||
currency: str = "sat"
|
currency: str = "sat"
|
||||||
shipping_zones: List[Zone] = []
|
shipping_zones: list[Zone] = []
|
||||||
config: StallConfig = StallConfig()
|
config: StallConfig = StallConfig()
|
||||||
pending: bool = False
|
pending: bool = False
|
||||||
|
|
||||||
"""Last published nostr event for this Stall"""
|
"""Last published nostr event for this Stall"""
|
||||||
event_id: Optional[str] = None
|
event_id: str | None = None
|
||||||
event_created_at: Optional[int] = None
|
event_created_at: int | None = None
|
||||||
|
|
||||||
def validate_stall(self):
|
def validate_stall(self):
|
||||||
for z in self.shipping_zones:
|
for z in self.shipping_zones:
|
||||||
|
|
@ -207,19 +229,19 @@ class ProductShippingCost(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ProductConfig(BaseModel):
|
class ProductConfig(BaseModel):
|
||||||
description: Optional[str] = None
|
description: str | None = None
|
||||||
currency: Optional[str] = None
|
currency: str | None = None
|
||||||
use_autoreply: Optional[bool] = False
|
use_autoreply: bool | None = False
|
||||||
autoreply_message: Optional[str] = None
|
autoreply_message: str | None = None
|
||||||
shipping: List[ProductShippingCost] = []
|
shipping: list[ProductShippingCost] = []
|
||||||
|
|
||||||
|
|
||||||
class Product(BaseModel, Nostrable):
|
class Product(BaseModel, Nostrable):
|
||||||
id: Optional[str] = None
|
id: str | None = None
|
||||||
stall_id: str
|
stall_id: str
|
||||||
name: str
|
name: str
|
||||||
categories: List[str] = []
|
categories: list[str] = []
|
||||||
images: List[str] = []
|
images: list[str] = []
|
||||||
price: float
|
price: float
|
||||||
quantity: int
|
quantity: int
|
||||||
active: bool = True
|
active: bool = True
|
||||||
|
|
@ -227,8 +249,8 @@ class Product(BaseModel, Nostrable):
|
||||||
config: ProductConfig = ProductConfig()
|
config: ProductConfig = ProductConfig()
|
||||||
|
|
||||||
"""Last published nostr event for this Product"""
|
"""Last published nostr event for this Product"""
|
||||||
event_id: Optional[str] = None
|
event_id: str | None = None
|
||||||
event_created_at: Optional[int] = None
|
event_created_at: int | None = None
|
||||||
|
|
||||||
def to_nostr_event(self, pubkey: str) -> NostrEvent:
|
def to_nostr_event(self, pubkey: str) -> NostrEvent:
|
||||||
content = {
|
content = {
|
||||||
|
|
@ -285,7 +307,7 @@ class ProductOverview(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
price: float
|
price: float
|
||||||
product_shipping_cost: Optional[float] = None
|
product_shipping_cost: float | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_product(cls, p: Product) -> "ProductOverview":
|
def from_product(cls, p: Product) -> "ProductOverview":
|
||||||
|
|
@ -302,21 +324,21 @@ class OrderItem(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class OrderContact(BaseModel):
|
class OrderContact(BaseModel):
|
||||||
nostr: Optional[str] = None
|
nostr: str | None = None
|
||||||
phone: Optional[str] = None
|
phone: str | None = None
|
||||||
email: Optional[str] = None
|
email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class OrderExtra(BaseModel):
|
class OrderExtra(BaseModel):
|
||||||
products: List[ProductOverview]
|
products: list[ProductOverview]
|
||||||
currency: str
|
currency: str
|
||||||
btc_price: str
|
btc_price: str
|
||||||
shipping_cost: float = 0
|
shipping_cost: float = 0
|
||||||
shipping_cost_sat: float = 0
|
shipping_cost_sat: float = 0
|
||||||
fail_message: Optional[str] = None
|
fail_message: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_products(cls, products: List[Product]):
|
async def from_products(cls, products: list[Product]):
|
||||||
currency = products[0].config.currency if len(products) else "sat"
|
currency = products[0].config.currency if len(products) else "sat"
|
||||||
exchange_rate = (
|
exchange_rate = (
|
||||||
await btc_price(currency) if currency and currency != "sat" else 1
|
await btc_price(currency) if currency and currency != "sat" else 1
|
||||||
|
|
@ -332,19 +354,19 @@ class OrderExtra(BaseModel):
|
||||||
|
|
||||||
class PartialOrder(BaseModel):
|
class PartialOrder(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
event_id: Optional[str] = None
|
event_id: str | None = None
|
||||||
event_created_at: Optional[int] = None
|
event_created_at: int | None = None
|
||||||
public_key: str
|
public_key: str
|
||||||
merchant_public_key: str
|
merchant_public_key: str
|
||||||
shipping_id: str
|
shipping_id: str
|
||||||
items: List[OrderItem]
|
items: list[OrderItem]
|
||||||
contact: Optional[OrderContact] = None
|
contact: OrderContact | None = None
|
||||||
address: Optional[str] = None
|
address: str | None = None
|
||||||
|
|
||||||
def validate_order(self):
|
def validate_order(self):
|
||||||
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
|
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
|
||||||
|
|
||||||
def validate_order_items(self, product_list: List[Product]):
|
def validate_order_items(self, product_list: list[Product]):
|
||||||
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
|
assert len(self.items) != 0, f"Order has no items. Order: '{self.id}'"
|
||||||
assert (
|
assert (
|
||||||
len(product_list) != 0
|
len(product_list) != 0
|
||||||
|
|
@ -365,8 +387,8 @@ class PartialOrder(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
async def costs_in_sats(
|
async def costs_in_sats(
|
||||||
self, products: List[Product], shipping_id: str, stall_shipping_cost: float
|
self, products: list[Product], shipping_id: str, stall_shipping_cost: float
|
||||||
) -> Tuple[float, float]:
|
) -> tuple[float, float]:
|
||||||
product_prices = {}
|
product_prices = {}
|
||||||
for p in products:
|
for p in products:
|
||||||
product_shipping_cost = next(
|
product_shipping_cost = next(
|
||||||
|
|
@ -395,7 +417,7 @@ class PartialOrder(BaseModel):
|
||||||
return product_cost, stall_shipping_cost
|
return product_cost, stall_shipping_cost
|
||||||
|
|
||||||
def receipt(
|
def receipt(
|
||||||
self, products: List[Product], shipping_id: str, stall_shipping_cost: float
|
self, products: list[Product], shipping_id: str, stall_shipping_cost: float
|
||||||
) -> str:
|
) -> str:
|
||||||
if len(products) == 0:
|
if len(products) == 0:
|
||||||
return "[No Products]"
|
return "[No Products]"
|
||||||
|
|
@ -444,7 +466,7 @@ class Order(PartialOrder):
|
||||||
total: float
|
total: float
|
||||||
paid: bool = False
|
paid: bool = False
|
||||||
shipped: bool = False
|
shipped: bool = False
|
||||||
time: Optional[int] = None
|
time: int | None = None
|
||||||
extra: OrderExtra
|
extra: OrderExtra
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -458,14 +480,14 @@ class Order(PartialOrder):
|
||||||
|
|
||||||
class OrderStatusUpdate(BaseModel):
|
class OrderStatusUpdate(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
message: Optional[str] = None
|
message: str | None = None
|
||||||
paid: Optional[bool] = False
|
paid: bool | None = False
|
||||||
shipped: Optional[bool] = None
|
shipped: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class OrderReissue(BaseModel):
|
class OrderReissue(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
shipping_id: Optional[str] = None
|
shipping_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class PaymentOption(BaseModel):
|
class PaymentOption(BaseModel):
|
||||||
|
|
@ -475,8 +497,8 @@ class PaymentOption(BaseModel):
|
||||||
|
|
||||||
class PaymentRequest(BaseModel):
|
class PaymentRequest(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
message: Optional[str] = None
|
message: str | None = None
|
||||||
payment_options: List[PaymentOption]
|
payment_options: list[PaymentOption]
|
||||||
|
|
||||||
|
|
||||||
######################################## MESSAGE #######################################
|
######################################## MESSAGE #######################################
|
||||||
|
|
@ -492,16 +514,16 @@ class DirectMessageType(Enum):
|
||||||
|
|
||||||
|
|
||||||
class PartialDirectMessage(BaseModel):
|
class PartialDirectMessage(BaseModel):
|
||||||
event_id: Optional[str] = None
|
event_id: str | None = None
|
||||||
event_created_at: Optional[int] = None
|
event_created_at: int | None = None
|
||||||
message: str
|
message: str
|
||||||
public_key: str
|
public_key: str
|
||||||
type: int = DirectMessageType.PLAIN_TEXT.value
|
type: int = DirectMessageType.PLAIN_TEXT.value
|
||||||
incoming: bool = False
|
incoming: bool = False
|
||||||
time: Optional[int] = None
|
time: int | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_message(cls, msg) -> Tuple[DirectMessageType, Optional[Any]]:
|
def parse_message(cls, msg) -> tuple[DirectMessageType, Any | None]:
|
||||||
try:
|
try:
|
||||||
msg_json = json.loads(msg)
|
msg_json = json.loads(msg)
|
||||||
if "type" in msg_json:
|
if "type" in msg_json:
|
||||||
|
|
@ -524,15 +546,15 @@ class DirectMessage(PartialDirectMessage):
|
||||||
|
|
||||||
|
|
||||||
class CustomerProfile(BaseModel):
|
class CustomerProfile(BaseModel):
|
||||||
name: Optional[str] = None
|
name: str | None = None
|
||||||
about: Optional[str] = None
|
about: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class Customer(BaseModel):
|
class Customer(BaseModel):
|
||||||
merchant_id: str
|
merchant_id: str
|
||||||
public_key: str
|
public_key: str
|
||||||
event_created_at: Optional[int] = None
|
event_created_at: int | None = None
|
||||||
profile: Optional[CustomerProfile] = None
|
profile: CustomerProfile | None = None
|
||||||
unread_messages: int = 0
|
unread_messages: int = 0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
180
nostr/nip44.py
180
nostr/nip44.py
|
|
@ -1,180 +0,0 @@
|
||||||
"""
|
|
||||||
NIP-44 v2: Encrypted Payloads (Versioned)
|
|
||||||
|
|
||||||
secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256, base64
|
|
||||||
|
|
||||||
Reference: https://github.com/nostr-protocol/nips/blob/master/44.md
|
|
||||||
"""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import math
|
|
||||||
import secrets
|
|
||||||
import struct
|
|
||||||
|
|
||||||
import coincurve
|
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
|
||||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
|
|
||||||
VERSION = 2
|
|
||||||
MIN_PLAINTEXT_SIZE = 1
|
|
||||||
MAX_PLAINTEXT_SIZE = 65535
|
|
||||||
|
|
||||||
|
|
||||||
def get_conversation_key(private_key_hex: str, public_key_hex: str) -> bytes:
|
|
||||||
"""
|
|
||||||
Calculate long-term conversation key between two users via ECDH + HKDF-extract.
|
|
||||||
Symmetric: get_conversation_key(a, B) == get_conversation_key(b, A)
|
|
||||||
"""
|
|
||||||
pk = coincurve.PublicKey(bytes.fromhex("02" + public_key_hex))
|
|
||||||
sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
|
|
||||||
shared_point = pk.multiply(sk.secret)
|
|
||||||
shared_x = shared_point.format(compressed=False)[1:33]
|
|
||||||
|
|
||||||
# HKDF-extract only (not expand) with salt='nip44-v2'
|
|
||||||
conversation_key = hmac.new(b"nip44-v2", shared_x, hashlib.sha256).digest()
|
|
||||||
return conversation_key
|
|
||||||
|
|
||||||
|
|
||||||
def get_message_keys(
|
|
||||||
conversation_key: bytes, nonce: bytes
|
|
||||||
) -> tuple[bytes, bytes, bytes]:
|
|
||||||
"""
|
|
||||||
Derive per-message keys from conversation_key and nonce using HKDF-expand.
|
|
||||||
Returns (chacha_key, chacha_nonce, hmac_key).
|
|
||||||
"""
|
|
||||||
if len(conversation_key) != 32:
|
|
||||||
raise ValueError("invalid conversation_key length")
|
|
||||||
if len(nonce) != 32:
|
|
||||||
raise ValueError("invalid nonce length")
|
|
||||||
|
|
||||||
keys = HKDFExpand(
|
|
||||||
algorithm=hashes.SHA256(),
|
|
||||||
length=76,
|
|
||||||
info=nonce,
|
|
||||||
).derive(conversation_key)
|
|
||||||
|
|
||||||
chacha_key = keys[0:32]
|
|
||||||
chacha_nonce = keys[32:44]
|
|
||||||
hmac_key = keys[44:76]
|
|
||||||
return chacha_key, chacha_nonce, hmac_key
|
|
||||||
|
|
||||||
|
|
||||||
def calc_padded_len(unpadded_len: int) -> int:
|
|
||||||
"""Calculate padded length using power-of-two chunking."""
|
|
||||||
if unpadded_len <= 0:
|
|
||||||
raise ValueError("invalid plaintext length")
|
|
||||||
if unpadded_len <= 32:
|
|
||||||
return 32
|
|
||||||
next_power = 1 << (math.floor(math.log2(unpadded_len - 1)) + 1)
|
|
||||||
if next_power <= 256:
|
|
||||||
chunk = 32
|
|
||||||
else:
|
|
||||||
chunk = next_power // 8
|
|
||||||
return chunk * (math.floor((unpadded_len - 1) / chunk) + 1)
|
|
||||||
|
|
||||||
|
|
||||||
def _pad(plaintext: str) -> bytes:
|
|
||||||
"""Convert plaintext string to padded byte array."""
|
|
||||||
unpadded = plaintext.encode("utf-8")
|
|
||||||
unpadded_len = len(unpadded)
|
|
||||||
if unpadded_len < MIN_PLAINTEXT_SIZE or unpadded_len > MAX_PLAINTEXT_SIZE:
|
|
||||||
raise ValueError(
|
|
||||||
f"invalid plaintext length: {unpadded_len} "
|
|
||||||
f"(must be {MIN_PLAINTEXT_SIZE}..{MAX_PLAINTEXT_SIZE})"
|
|
||||||
)
|
|
||||||
prefix = struct.pack(">H", unpadded_len)
|
|
||||||
padded_len = calc_padded_len(unpadded_len)
|
|
||||||
suffix = b"\x00" * (padded_len - unpadded_len)
|
|
||||||
return prefix + unpadded + suffix
|
|
||||||
|
|
||||||
|
|
||||||
def _unpad(padded: bytes) -> str:
|
|
||||||
"""Convert padded byte array back to plaintext string."""
|
|
||||||
unpadded_len = struct.unpack(">H", padded[0:2])[0]
|
|
||||||
unpadded = padded[2 : 2 + unpadded_len]
|
|
||||||
if (
|
|
||||||
unpadded_len == 0
|
|
||||||
or len(unpadded) != unpadded_len
|
|
||||||
or len(padded) != 2 + calc_padded_len(unpadded_len)
|
|
||||||
):
|
|
||||||
raise ValueError("invalid padding")
|
|
||||||
return unpadded.decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _hmac_aad(key: bytes, message: bytes, aad: bytes) -> bytes:
|
|
||||||
"""HMAC-SHA256 with AAD: hmac(key, aad || message)."""
|
|
||||||
if len(aad) != 32:
|
|
||||||
raise ValueError("AAD associated data must be 32 bytes")
|
|
||||||
return hmac.new(key, aad + message, hashlib.sha256).digest()
|
|
||||||
|
|
||||||
|
|
||||||
def _chacha20(key: bytes, nonce: bytes, data: bytes) -> bytes:
|
|
||||||
"""ChaCha20 encrypt/decrypt with initial counter = 0."""
|
|
||||||
# cryptography's ChaCha20 takes a 16-byte nonce: 4-byte counter (LE) + 12-byte nonce
|
|
||||||
full_nonce = b"\x00\x00\x00\x00" + nonce
|
|
||||||
cipher = Cipher(algorithms.ChaCha20(key, full_nonce), mode=None)
|
|
||||||
encryptor = cipher.encryptor()
|
|
||||||
return encryptor.update(data) + encryptor.finalize()
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_payload(payload: str) -> tuple[bytes, bytes, bytes]:
|
|
||||||
"""Decode base64 payload into (nonce, ciphertext, mac)."""
|
|
||||||
plen = len(payload)
|
|
||||||
if plen == 0 or payload[0] == "#":
|
|
||||||
raise ValueError("unknown version")
|
|
||||||
if plen < 132 or plen > 87472:
|
|
||||||
raise ValueError("invalid payload size")
|
|
||||||
|
|
||||||
data = base64.b64decode(payload)
|
|
||||||
dlen = len(data)
|
|
||||||
if dlen < 99 or dlen > 65603:
|
|
||||||
raise ValueError("invalid data size")
|
|
||||||
|
|
||||||
vers = data[0]
|
|
||||||
if vers != VERSION:
|
|
||||||
raise ValueError(f"unknown version {vers}")
|
|
||||||
|
|
||||||
nonce = data[1:33]
|
|
||||||
ciphertext = data[33 : dlen - 32]
|
|
||||||
mac = data[dlen - 32 : dlen]
|
|
||||||
return nonce, ciphertext, mac
|
|
||||||
|
|
||||||
|
|
||||||
def encrypt(
|
|
||||||
plaintext: str,
|
|
||||||
conversation_key: bytes,
|
|
||||||
nonce: bytes | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Encrypt plaintext using NIP-44 v2.
|
|
||||||
Returns base64-encoded payload.
|
|
||||||
"""
|
|
||||||
if nonce is None:
|
|
||||||
nonce = secrets.token_bytes(32)
|
|
||||||
if len(nonce) != 32:
|
|
||||||
raise ValueError("invalid nonce length")
|
|
||||||
|
|
||||||
chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce)
|
|
||||||
padded = _pad(plaintext)
|
|
||||||
ciphertext = _chacha20(chacha_key, chacha_nonce, padded)
|
|
||||||
mac = _hmac_aad(hmac_key, ciphertext, nonce)
|
|
||||||
return base64.b64encode(
|
|
||||||
struct.pack("B", VERSION) + nonce + ciphertext + mac
|
|
||||||
).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def decrypt(payload: str, conversation_key: bytes) -> str:
|
|
||||||
"""
|
|
||||||
Decrypt a NIP-44 v2 base64 payload.
|
|
||||||
Returns plaintext string.
|
|
||||||
"""
|
|
||||||
nonce, ciphertext, mac = _decode_payload(payload)
|
|
||||||
chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce)
|
|
||||||
calculated_mac = _hmac_aad(hmac_key, ciphertext, nonce)
|
|
||||||
if not hmac.compare_digest(calculated_mac, mac):
|
|
||||||
raise ValueError("invalid MAC")
|
|
||||||
padded_plaintext = _chacha20(chacha_key, chacha_nonce, ciphertext)
|
|
||||||
return _unpad(padded_plaintext)
|
|
||||||
178
nostr/nip59.py
178
nostr/nip59.py
|
|
@ -1,178 +0,0 @@
|
||||||
"""
|
|
||||||
NIP-59: Gift Wrap
|
|
||||||
|
|
||||||
Three-layer protocol for metadata-protected messaging:
|
|
||||||
1. Rumor (unsigned event) — carries content, deniable if leaked
|
|
||||||
2. Seal (kind 13) — encrypts rumor, signed by author, no recipient metadata
|
|
||||||
3. Gift Wrap (kind 1059) — encrypts seal with ephemeral key, has recipient p-tag
|
|
||||||
|
|
||||||
Reference: https://github.com/nostr-protocol/nips/blob/master/59.md
|
|
||||||
"""
|
|
||||||
|
|
||||||
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(event: NostrEvent, private_key_hex: str) -> NostrEvent:
|
|
||||||
"""Compute event id and sign it."""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def create_seal(
|
|
||||||
rumor: NostrEvent,
|
|
||||||
sender_privkey: str,
|
|
||||||
recipient_pubkey: str,
|
|
||||||
) -> NostrEvent:
|
|
||||||
"""
|
|
||||||
Create a kind 13 seal: encrypts the rumor for the recipient.
|
|
||||||
Signed by the sender. Tags are always empty.
|
|
||||||
"""
|
|
||||||
conv_key = get_conversation_key(sender_privkey, recipient_pubkey)
|
|
||||||
encrypted_rumor = nip44_encrypt(rumor.stringify(), conv_key)
|
|
||||||
|
|
||||||
seal = NostrEvent(
|
|
||||||
pubkey=_pubkey_from_privkey(sender_privkey),
|
|
||||||
created_at=_random_past_timestamp(),
|
|
||||||
kind=13,
|
|
||||||
tags=[],
|
|
||||||
content=encrypted_rumor,
|
|
||||||
)
|
|
||||||
return _sign_event(seal, sender_privkey)
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
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(wrap, ephemeral_privkey)
|
|
||||||
|
|
||||||
|
|
||||||
def unwrap_gift_wrap(
|
|
||||||
gift_wrap: NostrEvent,
|
|
||||||
recipient_privkey: str,
|
|
||||||
) -> NostrEvent:
|
|
||||||
"""
|
|
||||||
Decrypt a kind 1059 gift wrap to reveal the inner seal.
|
|
||||||
Uses the recipient's private key and the gift wrap's ephemeral pubkey.
|
|
||||||
"""
|
|
||||||
conv_key = get_conversation_key(recipient_privkey, gift_wrap.pubkey)
|
|
||||||
seal_json = nip44_decrypt(gift_wrap.content, conv_key)
|
|
||||||
return NostrEvent(**json.loads(seal_json))
|
|
||||||
|
|
||||||
|
|
||||||
def unseal(
|
|
||||||
seal: NostrEvent,
|
|
||||||
recipient_privkey: str,
|
|
||||||
) -> NostrEvent:
|
|
||||||
"""
|
|
||||||
Decrypt a kind 13 seal to reveal the inner rumor.
|
|
||||||
Uses the recipient's private key and the seal's pubkey (the sender).
|
|
||||||
Validates that the rumor's pubkey matches the seal's pubkey.
|
|
||||||
"""
|
|
||||||
conv_key = get_conversation_key(recipient_privkey, seal.pubkey)
|
|
||||||
rumor_json = nip44_decrypt(seal.content, conv_key)
|
|
||||||
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 ---
|
|
||||||
|
|
||||||
|
|
||||||
def wrap_message(
|
|
||||||
content: str,
|
|
||||||
sender_privkey: str,
|
|
||||||
sender_pubkey: str,
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
rumor = create_rumor(sender_pubkey, content, kind=kind, tags=tags)
|
|
||||||
seal = create_seal(rumor, sender_privkey, recipient_pubkey)
|
|
||||||
return create_gift_wrap(seal, recipient_pubkey)
|
|
||||||
|
|
||||||
|
|
||||||
def unwrap_message(
|
|
||||||
gift_wrap: NostrEvent,
|
|
||||||
recipient_privkey: str,
|
|
||||||
) -> NostrEvent:
|
|
||||||
"""
|
|
||||||
Full unwrap pipeline: gift wrap -> seal -> rumor.
|
|
||||||
Returns the rumor with sender pubkey and plaintext content.
|
|
||||||
"""
|
|
||||||
seal = unwrap_gift_wrap(gift_wrap, recipient_privkey)
|
|
||||||
return unseal(seal, recipient_privkey)
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
from asyncio import Queue
|
from asyncio import Queue
|
||||||
from collections import OrderedDict
|
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from typing import Callable, List, Optional
|
from typing import Callable, List, Optional
|
||||||
|
|
||||||
|
|
@ -14,8 +12,6 @@ from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash
|
||||||
|
|
||||||
from .event import NostrEvent
|
from .event import NostrEvent
|
||||||
|
|
||||||
MAX_SEEN_EVENTS = 1000
|
|
||||||
|
|
||||||
|
|
||||||
class NostrClient:
|
class NostrClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
@ -24,8 +20,6 @@ class NostrClient:
|
||||||
self.ws: Optional[WebSocketApp] = None
|
self.ws: Optional[WebSocketApp] = None
|
||||||
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
|
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
|
||||||
self.running = False
|
self.running = False
|
||||||
self._seen_events: OrderedDict[str, None] = OrderedDict()
|
|
||||||
self.last_event_at: float = 0
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_websocket_connected(self):
|
def is_websocket_connected(self):
|
||||||
|
|
@ -37,11 +31,9 @@ class NostrClient:
|
||||||
logger.debug(f"Connecting to websockets for 'nostrclient' extension...")
|
logger.debug(f"Connecting to websockets for 'nostrclient' extension...")
|
||||||
|
|
||||||
relay_endpoint = encrypt_internal_message("relay", urlsafe=True)
|
relay_endpoint = encrypt_internal_message("relay", urlsafe=True)
|
||||||
ws_url = f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}"
|
|
||||||
|
|
||||||
on_open, on_message, on_error, on_close = self._ws_handlers()
|
on_open, on_message, on_error, on_close = self._ws_handlers()
|
||||||
ws = WebSocketApp(
|
ws = WebSocketApp(
|
||||||
ws_url,
|
f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}",
|
||||||
on_message=on_message,
|
on_message=on_message,
|
||||||
on_open=on_open,
|
on_open=on_open,
|
||||||
on_close=on_close,
|
on_close=on_close,
|
||||||
|
|
@ -70,21 +62,10 @@ class NostrClient:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
await asyncio.sleep(60)
|
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):
|
async def get_event(self):
|
||||||
value = await self.recieve_event_queue.get()
|
value = await self.recieve_event_queue.get()
|
||||||
if isinstance(value, ValueError):
|
if isinstance(value, ValueError):
|
||||||
logger.error(f"[NOSTRMARKET] ❌ Queue returned error: {value}")
|
|
||||||
raise value
|
raise value
|
||||||
self.last_event_at = time.time()
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
async def publish_nostr_event(self, e: NostrEvent):
|
async def publish_nostr_event(self, e: NostrEvent):
|
||||||
|
|
@ -110,6 +91,10 @@ class NostrClient:
|
||||||
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
|
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
|
||||||
await self.send_req_queue.put(["REQ", self.subscription_id] + merchant_filters)
|
await self.send_req_queue.put(["REQ", self.subscription_id] + merchant_filters)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Subscribing to events for: {len(public_keys)} keys. New subscription id: {self.subscription_id}"
|
||||||
|
)
|
||||||
|
|
||||||
async def merchant_temp_subscription(self, pk, duration=10):
|
async def merchant_temp_subscription(self, pk, duration=10):
|
||||||
dm_filters = self._filters_for_direct_messages([pk], 0)
|
dm_filters = self._filters_for_direct_messages([pk], 0)
|
||||||
stall_filters = self._filters_for_stall_events([pk], 0)
|
stall_filters = self._filters_for_stall_events([pk], 0)
|
||||||
|
|
@ -150,13 +135,13 @@ class NostrClient:
|
||||||
logger.debug(ex)
|
logger.debug(ex)
|
||||||
|
|
||||||
def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List:
|
def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List:
|
||||||
# NIP-17/NIP-59: subscribe to kind 1059 gift wraps addressed to our merchants.
|
in_messages_filter = {"kinds": [4], "#p": public_keys}
|
||||||
# With gift wrapping, outgoing messages are self-wrapped (same p-tag filter).
|
out_messages_filter = {"kinds": [4], "authors": public_keys}
|
||||||
gift_wrap_filter: dict = {"kinds": [1059], "#p": public_keys}
|
|
||||||
if since and since != 0:
|
if since and since != 0:
|
||||||
gift_wrap_filter["since"] = since
|
in_messages_filter["since"] = since
|
||||||
|
out_messages_filter["since"] = since
|
||||||
|
|
||||||
return [gift_wrap_filter]
|
return [in_messages_filter, out_messages_filter]
|
||||||
|
|
||||||
def _filters_for_stall_events(self, public_keys: List[str], since: int) -> List:
|
def _filters_for_stall_events(self, public_keys: List[str], since: int) -> List:
|
||||||
stall_filter = {"kinds": [30017], "authors": public_keys}
|
stall_filter = {"kinds": [30017], "authors": public_keys}
|
||||||
|
|
@ -190,21 +175,16 @@ class NostrClient:
|
||||||
|
|
||||||
def _ws_handlers(self):
|
def _ws_handlers(self):
|
||||||
def on_open(_):
|
def on_open(_):
|
||||||
logger.debug("[NOSTRMARKET DEBUG] ✅ Connected to 'nostrclient' websocket successfully")
|
logger.info("Connected to 'nostrclient' websocket")
|
||||||
|
|
||||||
def on_message(_, message):
|
def on_message(_, message):
|
||||||
logger.debug(f"[NOSTRMARKET DEBUG] 📨 Received websocket message: {message[:200]}...")
|
self.recieve_event_queue.put_nowait(message)
|
||||||
try:
|
|
||||||
self.recieve_event_queue.put_nowait(message)
|
|
||||||
logger.debug(f"[NOSTRMARKET DEBUG] 📤 Message queued successfully")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[NOSTRMARKET] ❌ Failed to queue message: {e}")
|
|
||||||
|
|
||||||
def on_error(_, error):
|
def on_error(_, error):
|
||||||
logger.warning(f"[NOSTRMARKET] ❌ Websocket error: {error}")
|
logger.warning(error)
|
||||||
|
|
||||||
def on_close(x, status_code, message):
|
def on_close(x, status_code, message):
|
||||||
logger.warning(f"[NOSTRMARKET] 🔌 Websocket closed: {x}: '{status_code}' '{message}'")
|
logger.warning(f"Websocket closed: {x}: '{status_code}' '{message}'")
|
||||||
# force re-subscribe
|
# force re-subscribe
|
||||||
self.recieve_event_queue.put_nowait(ValueError("Websocket close."))
|
self.recieve_event_queue.put_nowait(ValueError("Websocket close."))
|
||||||
|
|
||||||
|
|
|
||||||
190
services.py
190
services.py
|
|
@ -1,8 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import List, Optional, Tuple
|
|
||||||
|
|
||||||
from lnbits.bolt11 import decode
|
from bolt11 import decode
|
||||||
from lnbits.core.crud import get_wallet
|
from lnbits.core.crud import get_wallet
|
||||||
from lnbits.core.services import create_invoice, websocket_updater
|
from lnbits.core.services import create_invoice, websocket_updater
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
@ -56,17 +55,15 @@ from .models import (
|
||||||
Stall,
|
Stall,
|
||||||
)
|
)
|
||||||
from .nostr.event import NostrEvent
|
from .nostr.event import NostrEvent
|
||||||
from .nostr.nip59 import unwrap_message, wrap_message
|
|
||||||
|
|
||||||
|
|
||||||
async def create_new_order(
|
async def create_new_order(
|
||||||
merchant_public_key: str, data: PartialOrder
|
merchant_public_key: str, data: PartialOrder
|
||||||
) -> Optional[PaymentRequest]:
|
) -> PaymentRequest | None:
|
||||||
merchant = await get_merchant_by_pubkey(merchant_public_key)
|
merchant = await get_merchant_by_pubkey(merchant_public_key)
|
||||||
assert merchant, "Cannot find merchant for order!"
|
assert merchant, "Cannot find merchant for order!"
|
||||||
|
|
||||||
existing_order = await get_order(merchant.id, data.id)
|
if await get_order(merchant.id, data.id):
|
||||||
if existing_order:
|
|
||||||
return None
|
return None
|
||||||
if data.event_id and await get_order_by_event_id(merchant.id, data.event_id):
|
if data.event_id and await get_order_by_event_id(merchant.id, data.event_id):
|
||||||
return None
|
return None
|
||||||
|
|
@ -76,24 +73,20 @@ async def create_new_order(
|
||||||
)
|
)
|
||||||
await create_order(merchant.id, order)
|
await create_order(merchant.id, order)
|
||||||
|
|
||||||
payment_request = PaymentRequest(
|
return PaymentRequest(
|
||||||
id=data.id,
|
id=data.id,
|
||||||
payment_options=[PaymentOption(type="ln", link=invoice)],
|
payment_options=[PaymentOption(type="ln", link=invoice)],
|
||||||
message=receipt,
|
message=receipt,
|
||||||
)
|
)
|
||||||
return payment_request
|
|
||||||
|
|
||||||
|
|
||||||
async def build_order_with_payment(
|
async def build_order_with_payment(
|
||||||
merchant_id: str, merchant_public_key: str, data: PartialOrder
|
merchant_id: str, merchant_public_key: str, data: PartialOrder
|
||||||
):
|
):
|
||||||
|
|
||||||
products = await get_products_by_ids(
|
products = await get_products_by_ids(
|
||||||
merchant_id, [p.product_id for p in data.items]
|
merchant_id, [p.product_id for p in data.items]
|
||||||
)
|
)
|
||||||
|
|
||||||
data.validate_order_items(products)
|
data.validate_order_items(products)
|
||||||
|
|
||||||
shipping_zone = await get_zone(merchant_id, data.shipping_id)
|
shipping_zone = await get_zone(merchant_id, data.shipping_id)
|
||||||
assert shipping_zone, f"Shipping zone not found for order '{data.id}'"
|
assert shipping_zone, f"Shipping zone not found for order '{data.id}'"
|
||||||
|
|
||||||
|
|
@ -101,7 +94,6 @@ async def build_order_with_payment(
|
||||||
product_cost_sat, shipping_cost_sat = await data.costs_in_sats(
|
product_cost_sat, shipping_cost_sat = await data.costs_in_sats(
|
||||||
products, shipping_zone.id, shipping_zone.cost
|
products, shipping_zone.id, shipping_zone.cost
|
||||||
)
|
)
|
||||||
|
|
||||||
receipt = data.receipt(products, shipping_zone.id, shipping_zone.cost)
|
receipt = data.receipt(products, shipping_zone.id, shipping_zone.cost)
|
||||||
|
|
||||||
wallet_id = await get_wallet_for_product(data.items[0].product_id)
|
wallet_id = await get_wallet_for_product(data.items[0].product_id)
|
||||||
|
|
@ -112,13 +104,11 @@ async def build_order_with_payment(
|
||||||
merchant_id, product_ids, data.items
|
merchant_id, product_ids, data.items
|
||||||
)
|
)
|
||||||
if not success:
|
if not success:
|
||||||
logger.error(f"[NOSTRMARKET] ❌ Product quantity check failed: {message}")
|
|
||||||
raise ValueError(message)
|
raise ValueError(message)
|
||||||
|
|
||||||
total_amount_sat = round(product_cost_sat + shipping_cost_sat)
|
|
||||||
payment = await create_invoice(
|
payment = await create_invoice(
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
amount=total_amount_sat,
|
amount=round(product_cost_sat + shipping_cost_sat),
|
||||||
memo=f"Order '{data.id}' for pubkey '{data.public_key}'",
|
memo=f"Order '{data.id}' for pubkey '{data.public_key}'",
|
||||||
extra={
|
extra={
|
||||||
"tag": "nostrmarket",
|
"tag": "nostrmarket",
|
||||||
|
|
@ -146,7 +136,7 @@ async def update_merchant_to_nostr(
|
||||||
merchant: Merchant, delete_merchant=False
|
merchant: Merchant, delete_merchant=False
|
||||||
) -> Merchant:
|
) -> Merchant:
|
||||||
stalls = await get_stalls(merchant.id)
|
stalls = await get_stalls(merchant.id)
|
||||||
event: Optional[NostrEvent] = None
|
event: NostrEvent | None = None
|
||||||
for stall in stalls:
|
for stall in stalls:
|
||||||
assert stall.id
|
assert stall.id
|
||||||
products = await get_products(merchant.id, stall.id)
|
products = await get_products(merchant.id, stall.id)
|
||||||
|
|
@ -230,7 +220,7 @@ async def notify_client_of_order_status(
|
||||||
|
|
||||||
async def update_products_for_order(
|
async def update_products_for_order(
|
||||||
merchant: Merchant, order: Order
|
merchant: Merchant, order: Order
|
||||||
) -> Tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
product_ids = [i.product_id for i in order.items]
|
product_ids = [i.product_id for i in order.items]
|
||||||
success, products, message = await compute_products_new_quantity(
|
success, products, message = await compute_products_new_quantity(
|
||||||
merchant.id, product_ids, order.items
|
merchant.id, product_ids, order.items
|
||||||
|
|
@ -271,34 +261,19 @@ async def send_dm(
|
||||||
other_pubkey: str,
|
other_pubkey: str,
|
||||||
type_: int,
|
type_: int,
|
||||||
dm_content: str,
|
dm_content: str,
|
||||||
) -> DirectMessage:
|
):
|
||||||
# Wrap message to recipient via NIP-59 gift wrap
|
dm_event = merchant.build_dm_event(dm_content, other_pubkey)
|
||||||
gift_wrap = wrap_message(
|
|
||||||
dm_content,
|
|
||||||
merchant.private_key,
|
|
||||||
merchant.public_key,
|
|
||||||
other_pubkey,
|
|
||||||
)
|
|
||||||
|
|
||||||
dm = PartialDirectMessage(
|
dm = PartialDirectMessage(
|
||||||
event_id=gift_wrap.id,
|
event_id=dm_event.id,
|
||||||
event_created_at=gift_wrap.created_at,
|
event_created_at=dm_event.created_at,
|
||||||
message=dm_content,
|
message=dm_content,
|
||||||
public_key=other_pubkey,
|
public_key=other_pubkey,
|
||||||
type=type_,
|
type=type_,
|
||||||
)
|
)
|
||||||
dm_reply = await create_direct_message(merchant.id, dm)
|
dm_reply = await create_direct_message(merchant.id, dm)
|
||||||
|
|
||||||
await nostr_client.publish_nostr_event(gift_wrap)
|
await nostr_client.publish_nostr_event(dm_event)
|
||||||
|
|
||||||
# Also wrap a copy to self for archival
|
|
||||||
self_wrap = wrap_message(
|
|
||||||
dm_content,
|
|
||||||
merchant.private_key,
|
|
||||||
merchant.public_key,
|
|
||||||
merchant.public_key,
|
|
||||||
)
|
|
||||||
await nostr_client.publish_nostr_event(self_wrap)
|
|
||||||
|
|
||||||
await websocket_updater(
|
await websocket_updater(
|
||||||
merchant.id,
|
merchant.id,
|
||||||
|
|
@ -311,13 +286,11 @@ async def send_dm(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return dm_reply
|
|
||||||
|
|
||||||
|
|
||||||
async def compute_products_new_quantity(
|
async def compute_products_new_quantity(
|
||||||
merchant_id: str, product_ids: List[str], items: List[OrderItem]
|
merchant_id: str, product_ids: list[str], items: list[OrderItem]
|
||||||
) -> Tuple[bool, List[Product], str]:
|
) -> tuple[bool, list[Product], str]:
|
||||||
products: List[Product] = await get_products_by_ids(merchant_id, product_ids)
|
products: list[Product] = await get_products_by_ids(merchant_id, product_ids)
|
||||||
|
|
||||||
for p in products:
|
for p in products:
|
||||||
required_quantity = next(
|
required_quantity = next(
|
||||||
|
|
@ -340,38 +313,23 @@ async def compute_products_new_quantity(
|
||||||
|
|
||||||
async def process_nostr_message(msg: str):
|
async def process_nostr_message(msg: str):
|
||||||
try:
|
try:
|
||||||
parsed_msg = json.loads(msg)
|
type_, *rest = json.loads(msg)
|
||||||
type_, *rest = parsed_msg
|
|
||||||
|
|
||||||
|
|
||||||
if type_.upper() == "EVENT":
|
if type_.upper() == "EVENT":
|
||||||
if len(rest) < 2:
|
|
||||||
logger.warning(f"[NOSTRMARKET] ⚠️ EVENT message missing data: {rest}")
|
|
||||||
return
|
|
||||||
_, event = rest
|
_, event = rest
|
||||||
event = NostrEvent(**event)
|
event = NostrEvent(**event)
|
||||||
|
|
||||||
# Deduplicate events (overlap resubscriptions may deliver duplicates)
|
|
||||||
if nostr_client.is_duplicate_event(event.id):
|
|
||||||
return
|
|
||||||
|
|
||||||
if event.kind == 0:
|
if event.kind == 0:
|
||||||
await _handle_customer_profile_update(event)
|
await _handle_customer_profile_update(event)
|
||||||
elif event.kind == 1059:
|
elif event.kind == 4:
|
||||||
await _handle_gift_wrap(event)
|
await _handle_nip04_message(event)
|
||||||
elif event.kind == 30017:
|
elif event.kind == 30017:
|
||||||
await _handle_stall(event)
|
await _handle_stall(event)
|
||||||
elif event.kind == 30018:
|
elif event.kind == 30018:
|
||||||
await _handle_product(event)
|
await _handle_product(event)
|
||||||
else:
|
|
||||||
logger.info(f"[NOSTRMARKET] ❓ Unhandled event kind: {event.kind} - event: {event.id}")
|
|
||||||
return
|
return
|
||||||
else:
|
|
||||||
logger.info(f"[NOSTRMARKET] 🔄 Non-EVENT message type: {type_}")
|
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.error(f"[NOSTRMARKET] ❌ Error processing nostr message: {ex}")
|
logger.debug(ex)
|
||||||
logger.error(f"[NOSTRMARKET] 📄 Raw message that failed: {msg}")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_or_update_order_from_dm(
|
async def create_or_update_order_from_dm(
|
||||||
|
|
@ -452,41 +410,29 @@ async def extract_customer_order_from_dm(
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
async def _handle_gift_wrap(event: NostrEvent):
|
async def _handle_nip04_message(event: NostrEvent):
|
||||||
"""Handle an incoming kind 1059 gift wrap event (NIP-59/NIP-17)."""
|
merchant_public_key = event.pubkey
|
||||||
|
merchant = await get_merchant_by_pubkey(merchant_public_key)
|
||||||
|
|
||||||
p_tags = event.tag_values("p")
|
|
||||||
if not p_tags:
|
|
||||||
logger.warning(f"[NOSTRMARKET] ⚠️ Gift wrap has no p-tag: {event.id}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# The p-tag identifies the recipient of the gift wrap
|
|
||||||
recipient_pubkey = p_tags[0]
|
|
||||||
merchant = await get_merchant_by_pubkey(recipient_pubkey)
|
|
||||||
if not merchant:
|
if not merchant:
|
||||||
logger.warning(
|
p_tags = event.tag_values("p")
|
||||||
f"[NOSTRMARKET] ⚠️ No merchant found for gift wrap recipient: {recipient_pubkey}"
|
if len(p_tags) and p_tags[0]:
|
||||||
|
merchant_public_key = p_tags[0]
|
||||||
|
merchant = await get_merchant_by_pubkey(merchant_public_key)
|
||||||
|
|
||||||
|
assert merchant, f"Merchant not found for public key '{merchant_public_key}'"
|
||||||
|
|
||||||
|
if event.pubkey == merchant_public_key:
|
||||||
|
assert len(event.tag_values("p")) != 0, "Outgong message has no 'p' tag"
|
||||||
|
clear_text_msg = merchant.decrypt_message(
|
||||||
|
event.content, event.tag_values("p")[0]
|
||||||
)
|
)
|
||||||
return
|
await _handle_outgoing_dms(event, merchant, clear_text_msg)
|
||||||
|
elif event.has_tag_value("p", merchant_public_key):
|
||||||
try:
|
clear_text_msg = merchant.decrypt_message(event.content, event.pubkey)
|
||||||
rumor = unwrap_message(event, merchant.private_key)
|
await _handle_incoming_dms(event, merchant, clear_text_msg)
|
||||||
except Exception as ex:
|
else:
|
||||||
logger.error(f"[NOSTRMARKET] ❌ Failed to unwrap gift wrap {event.id}: {ex}")
|
logger.warning(f"Bad NIP04 event: '{event.id}'")
|
||||||
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(
|
async def _handle_incoming_dms(
|
||||||
|
|
@ -536,18 +482,17 @@ async def _handle_outgoing_dms(
|
||||||
|
|
||||||
async def _handle_incoming_structured_dm(
|
async def _handle_incoming_structured_dm(
|
||||||
merchant: Merchant, dm: DirectMessage, json_data: dict
|
merchant: Merchant, dm: DirectMessage, json_data: dict
|
||||||
) -> Tuple[DirectMessageType, Optional[str]]:
|
) -> tuple[DirectMessageType, str | None]:
|
||||||
try:
|
try:
|
||||||
if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active:
|
if dm.type == DirectMessageType.CUSTOMER_ORDER.value and merchant.config.active:
|
||||||
json_resp = await _handle_new_order(
|
json_resp = await _handle_new_order(
|
||||||
merchant.id, merchant.public_key, dm, json_data
|
merchant.id, merchant.public_key, dm, json_data
|
||||||
)
|
)
|
||||||
|
|
||||||
return DirectMessageType.PAYMENT_REQUEST, json_resp
|
return DirectMessageType.PAYMENT_REQUEST, json_resp
|
||||||
else:
|
|
||||||
logger.info(f"[NOSTRMARKET] Skipping order processing - type: {dm.type}, expected: {DirectMessageType.CUSTOMER_ORDER.value}, merchant_active: {merchant.config.active}")
|
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.error(f"[NOSTRMARKET] Error in _handle_incoming_structured_dm: {ex}")
|
logger.warning(ex)
|
||||||
|
|
||||||
return DirectMessageType.PLAIN_TEXT, None
|
return DirectMessageType.PLAIN_TEXT, None
|
||||||
|
|
||||||
|
|
@ -586,21 +531,16 @@ async def _persist_dm(
|
||||||
async def reply_to_structured_dm(
|
async def reply_to_structured_dm(
|
||||||
merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str
|
merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str
|
||||||
):
|
):
|
||||||
gift_wrap = wrap_message(
|
dm_event = merchant.build_dm_event(dm_reply, customer_pubkey)
|
||||||
dm_reply,
|
|
||||||
merchant.private_key,
|
|
||||||
merchant.public_key,
|
|
||||||
customer_pubkey,
|
|
||||||
)
|
|
||||||
dm = PartialDirectMessage(
|
dm = PartialDirectMessage(
|
||||||
event_id=gift_wrap.id,
|
event_id=dm_event.id,
|
||||||
event_created_at=gift_wrap.created_at,
|
event_created_at=dm_event.created_at,
|
||||||
message=dm_reply,
|
message=dm_reply,
|
||||||
public_key=customer_pubkey,
|
public_key=customer_pubkey,
|
||||||
type=dm_type,
|
type=dm_type,
|
||||||
)
|
)
|
||||||
await create_direct_message(merchant.id, dm)
|
await create_direct_message(merchant.id, dm)
|
||||||
await nostr_client.publish_nostr_event(gift_wrap)
|
await nostr_client.publish_nostr_event(dm_event)
|
||||||
|
|
||||||
await websocket_updater(
|
await websocket_updater(
|
||||||
merchant.id,
|
merchant.id,
|
||||||
|
|
@ -633,31 +573,9 @@ async def _handle_new_order(
|
||||||
wallet = await get_wallet(wallet_id)
|
wallet = await get_wallet(wallet_id)
|
||||||
assert wallet, f"Cannot find wallet for product id: {first_product_id}"
|
assert wallet, f"Cannot find wallet for product id: {first_product_id}"
|
||||||
|
|
||||||
|
|
||||||
payment_req = await create_new_order(merchant_public_key, partial_order)
|
payment_req = await create_new_order(merchant_public_key, partial_order)
|
||||||
|
|
||||||
if payment_req is None:
|
|
||||||
# Return existing order data instead of creating a failed order
|
|
||||||
existing_order = await get_order(merchant_id, partial_order.id)
|
|
||||||
if existing_order and existing_order.invoice_id != "None":
|
|
||||||
# Order exists with invoice, return existing payment request
|
|
||||||
duplicate_response = json.dumps({
|
|
||||||
"type": DirectMessageType.PAYMENT_REQUEST.value,
|
|
||||||
"id": existing_order.id,
|
|
||||||
"message": "Order already received and processed",
|
|
||||||
"payment_options": []
|
|
||||||
}, separators=(",", ":"), ensure_ascii=False)
|
|
||||||
return duplicate_response
|
|
||||||
else:
|
|
||||||
# Order exists but no invoice, skip processing
|
|
||||||
logger.info(f"[NOSTRMARKET] Order exists but no invoice, returning empty string")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[NOSTRMARKET] Error creating order: {e}")
|
logger.debug(e)
|
||||||
logger.error(f"[NOSTRMARKET] Order data: {json_data}")
|
|
||||||
logger.error(f"[NOSTRMARKET] Exception type: {type(e).__name__}")
|
|
||||||
logger.error(f"[NOSTRMARKET] Exception details: {str(e)}")
|
|
||||||
payment_req = await create_new_failed_order(
|
payment_req = await create_new_failed_order(
|
||||||
merchant_id,
|
merchant_id,
|
||||||
merchant_public_key,
|
merchant_public_key,
|
||||||
|
|
@ -665,17 +583,12 @@ async def _handle_new_order(
|
||||||
json_data,
|
json_data,
|
||||||
"Order received, but cannot be processed. Please contact merchant.",
|
"Order received, but cannot be processed. Please contact merchant.",
|
||||||
)
|
)
|
||||||
|
assert payment_req
|
||||||
if not payment_req:
|
|
||||||
logger.error(f"[NOSTRMARKET] No payment request returned for order: {partial_order.id}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"type": DirectMessageType.PAYMENT_REQUEST.value,
|
"type": DirectMessageType.PAYMENT_REQUEST.value,
|
||||||
**payment_req.dict(),
|
**payment_req.dict(),
|
||||||
}
|
}
|
||||||
response_json = json.dumps(response, separators=(",", ":"), ensure_ascii=False)
|
return json.dumps(response, separators=(",", ":"), ensure_ascii=False)
|
||||||
return response_json
|
|
||||||
|
|
||||||
|
|
||||||
async def create_new_failed_order(
|
async def create_new_failed_order(
|
||||||
|
|
@ -708,11 +621,8 @@ async def subscribe_to_all_merchants():
|
||||||
last_stall_time = await get_last_stall_update_time()
|
last_stall_time = await get_last_stall_update_time()
|
||||||
last_prod_time = await get_last_product_update_time()
|
last_prod_time = await get_last_product_update_time()
|
||||||
|
|
||||||
# Make dm_time more lenient by subtracting 5 minutes to avoid missing recent events
|
|
||||||
lenient_dm_time = max(0, last_dm_time - 300) if last_dm_time > 0 else 0
|
|
||||||
|
|
||||||
await nostr_client.subscribe_merchants(
|
await nostr_client.subscribe_merchants(
|
||||||
public_keys, lenient_dm_time, last_stall_time, last_prod_time, 0
|
public_keys, last_dm_time, last_stall_time, last_prod_time, 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,19 +49,46 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
generateKeys: async function () {
|
generateKeys: function () {
|
||||||
// No longer need to generate keys here - the backend will use user's existing keypairs
|
const privateKey = nostr.generatePrivateKey()
|
||||||
await this.createMerchant()
|
const publicKey = nostr.getPublicKey(privateKey)
|
||||||
|
this.generateKeyDialog.privateKey = privateKey
|
||||||
|
this.generateKeyDialog.nsec = nostr.nip19.nsecEncode(privateKey)
|
||||||
|
this.generateKeyDialog.npub = nostr.nip19.npubEncode(publicKey)
|
||||||
|
this.generateKeyDialog.showNsec = false
|
||||||
|
this.generateKeyDialog.show = true
|
||||||
|
},
|
||||||
|
confirmGenerateKey: async function () {
|
||||||
|
this.generateKeyDialog.show = false
|
||||||
|
await this.createMerchant(this.generateKeyDialog.privateKey)
|
||||||
},
|
},
|
||||||
importKeys: async function () {
|
importKeys: async function () {
|
||||||
this.importKeyDialog.show = false
|
this.importKeyDialog.show = false
|
||||||
// Import keys functionality removed since we use user's native keypairs
|
let privateKey = this.importKeyDialog.data.privateKey
|
||||||
// Show a message that this is no longer needed
|
if (!privateKey) {
|
||||||
this.$q.notify({
|
return
|
||||||
type: 'info',
|
}
|
||||||
message: 'Merchants now use your account Nostr keys automatically. Key import is no longer needed.',
|
try {
|
||||||
timeout: 3000
|
if (privateKey.toLowerCase().startsWith('nsec')) {
|
||||||
})
|
privateKey = nostr.nip19.decode(privateKey).data
|
||||||
|
}
|
||||||
|
// Check if this key is already in use
|
||||||
|
const publicKey = nostr.getPublicKey(privateKey)
|
||||||
|
if (this.merchant?.public_key === publicKey) {
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'This key is already your current profile'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: `${error}`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.createMerchant(privateKey)
|
||||||
},
|
},
|
||||||
showImportKeysDialog: async function () {
|
showImportKeysDialog: async function () {
|
||||||
this.importKeyDialog.show = true
|
this.importKeyDialog.show = true
|
||||||
|
|
@ -116,9 +143,12 @@ window.app = Vue.createApp({
|
||||||
this.showKeys = false
|
this.showKeys = false
|
||||||
this.stallCount = 0
|
this.stallCount = 0
|
||||||
},
|
},
|
||||||
createMerchant: async function () {
|
createMerchant: async function (privateKey) {
|
||||||
try {
|
try {
|
||||||
|
const pubkey = nostr.getPublicKey(privateKey)
|
||||||
const payload = {
|
const payload = {
|
||||||
|
private_key: privateKey,
|
||||||
|
public_key: pubkey,
|
||||||
config: {}
|
config: {}
|
||||||
}
|
}
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
|
|
|
||||||
32
tasks.py
32
tasks.py
|
|
@ -1,5 +1,4 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
|
||||||
from asyncio import Queue
|
from asyncio import Queue
|
||||||
|
|
||||||
from lnbits.core.models import Payment
|
from lnbits.core.models import Payment
|
||||||
|
|
@ -10,13 +9,9 @@ from .nostr.nostr_client import NostrClient
|
||||||
from .services import (
|
from .services import (
|
||||||
handle_order_paid,
|
handle_order_paid,
|
||||||
process_nostr_message,
|
process_nostr_message,
|
||||||
resubscribe_to_all_merchants,
|
|
||||||
subscribe_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():
|
async def wait_for_paid_invoices():
|
||||||
invoice_queue = Queue()
|
invoice_queue = Queue()
|
||||||
|
|
@ -40,38 +35,13 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_nostr_events(nostr_client: NostrClient):
|
async def wait_for_nostr_events(nostr_client: NostrClient):
|
||||||
logger.info("[NOSTRMARKET] Starting wait_for_nostr_events task")
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
logger.info("[NOSTRMARKET] Subscribing to all merchants...")
|
|
||||||
await subscribe_to_all_merchants()
|
await subscribe_to_all_merchants()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
message = await nostr_client.get_event()
|
message = await nostr_client.get_event()
|
||||||
await process_nostr_message(message)
|
await process_nostr_message(message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[NOSTRMARKET] Subscription failed. Retrying in 10s: {e}")
|
logger.warning(f"Subcription failed. Will retry in one minute: {e}")
|
||||||
await asyncio.sleep(10)
|
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}")
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
"""
|
|
||||||
Stub out the nostrmarket root package and all LNbits dependencies so that
|
|
||||||
nostr/* unit tests can run without the full LNbits environment.
|
|
||||||
|
|
||||||
pytest walks up from tests/ and tries to import the parent __init__.py,
|
|
||||||
which pulls in fastapi, lnbits, websocket, etc. We preemptively register
|
|
||||||
the parent package as a simple module so that import never happens.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Register 'nostrmarket' as an already-imported namespace package
|
|
||||||
# pointing at the extension root, so pytest doesn't try to exec __init__.py
|
|
||||||
_ext_root = Path(__file__).resolve().parent.parent
|
|
||||||
_pkg = types.ModuleType("nostrmarket")
|
|
||||||
_pkg.__path__ = [str(_ext_root)]
|
|
||||||
_pkg.__package__ = "nostrmarket"
|
|
||||||
sys.modules["nostrmarket"] = _pkg
|
|
||||||
|
|
||||||
# Also ensure the nostr subpackage is importable
|
|
||||||
_nostr_dir = _ext_root / "nostr"
|
|
||||||
_nostr_pkg = types.ModuleType("nostrmarket.nostr")
|
|
||||||
_nostr_pkg.__path__ = [str(_nostr_dir)]
|
|
||||||
_nostr_pkg.__package__ = "nostrmarket.nostr"
|
|
||||||
sys.modules["nostrmarket.nostr"] = _nostr_pkg
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
"""Tests for NIP-44 v2 encryption against official spec test vectors."""
|
|
||||||
|
|
||||||
import coincurve
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nostr.nip44 import (
|
|
||||||
calc_padded_len,
|
|
||||||
decrypt,
|
|
||||||
encrypt,
|
|
||||||
get_conversation_key,
|
|
||||||
get_message_keys,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def pubkey_from_secret(secret_hex: str) -> str:
|
|
||||||
"""Derive x-only public key hex from secret key hex."""
|
|
||||||
sk = coincurve.PrivateKey(bytes.fromhex(secret_hex))
|
|
||||||
return sk.public_key.format(compressed=True)[1:].hex()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Test vector from NIP-44 spec ---
|
|
||||||
|
|
||||||
SPEC_VECTOR = {
|
|
||||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
|
||||||
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
|
|
||||||
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
|
|
||||||
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
|
|
||||||
"plaintext": "a",
|
|
||||||
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestConversationKey:
|
|
||||||
def test_spec_vector(self):
|
|
||||||
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
|
|
||||||
key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
|
|
||||||
assert key.hex() == SPEC_VECTOR["conversation_key"]
|
|
||||||
|
|
||||||
def test_symmetric(self):
|
|
||||||
"""conv(a, B) == conv(b, A)"""
|
|
||||||
pub1 = pubkey_from_secret(SPEC_VECTOR["sec1"])
|
|
||||||
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
|
|
||||||
key_ab = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
|
|
||||||
key_ba = get_conversation_key(SPEC_VECTOR["sec2"], pub1)
|
|
||||||
assert key_ab == key_ba
|
|
||||||
|
|
||||||
|
|
||||||
class TestMessageKeys:
|
|
||||||
def test_returns_correct_lengths(self):
|
|
||||||
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
|
|
||||||
nonce = bytes.fromhex(SPEC_VECTOR["nonce"])
|
|
||||||
chacha_key, chacha_nonce, hmac_key = get_message_keys(conv_key, nonce)
|
|
||||||
assert len(chacha_key) == 32
|
|
||||||
assert len(chacha_nonce) == 12
|
|
||||||
assert len(hmac_key) == 32
|
|
||||||
|
|
||||||
def test_rejects_bad_key_length(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
get_message_keys(b"\x00" * 16, b"\x00" * 32)
|
|
||||||
|
|
||||||
def test_rejects_bad_nonce_length(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
get_message_keys(b"\x00" * 32, b"\x00" * 16)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPadding:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"unpadded,expected",
|
|
||||||
[
|
|
||||||
(1, 32),
|
|
||||||
(2, 32),
|
|
||||||
(31, 32),
|
|
||||||
(32, 32),
|
|
||||||
(33, 64),
|
|
||||||
(64, 64),
|
|
||||||
(65, 96),
|
|
||||||
(256, 256),
|
|
||||||
(257, 320),
|
|
||||||
(1024, 1024),
|
|
||||||
(65535, 65536),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_calc_padded_len(self, unpadded, expected):
|
|
||||||
assert calc_padded_len(unpadded) == expected
|
|
||||||
|
|
||||||
def test_rejects_zero(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
calc_padded_len(0)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEncryptDecrypt:
|
|
||||||
def test_spec_vector(self):
|
|
||||||
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
|
|
||||||
nonce = bytes.fromhex(SPEC_VECTOR["nonce"])
|
|
||||||
payload = encrypt(SPEC_VECTOR["plaintext"], conv_key, nonce)
|
|
||||||
assert payload == SPEC_VECTOR["payload"]
|
|
||||||
|
|
||||||
def test_spec_vector_decrypt(self):
|
|
||||||
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
|
|
||||||
plaintext = decrypt(SPEC_VECTOR["payload"], conv_key)
|
|
||||||
assert plaintext == SPEC_VECTOR["plaintext"]
|
|
||||||
|
|
||||||
def test_round_trip_short(self):
|
|
||||||
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
|
|
||||||
conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
|
|
||||||
msg = "x"
|
|
||||||
assert decrypt(encrypt(msg, conv_key), conv_key) == msg
|
|
||||||
|
|
||||||
def test_round_trip_long(self):
|
|
||||||
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
|
|
||||||
conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
|
|
||||||
msg = "A" * 65535
|
|
||||||
assert decrypt(encrypt(msg, conv_key), conv_key) == msg
|
|
||||||
|
|
||||||
def test_round_trip_unicode(self):
|
|
||||||
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
|
|
||||||
conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
|
|
||||||
msg = "hello world! \U0001f680\U0001f30e\U0001f4ac"
|
|
||||||
assert decrypt(encrypt(msg, conv_key), conv_key) == msg
|
|
||||||
|
|
||||||
def test_tampered_mac_rejected(self):
|
|
||||||
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
|
|
||||||
payload = SPEC_VECTOR["payload"]
|
|
||||||
tampered = payload[:-1] + ("a" if payload[-1] != "a" else "b")
|
|
||||||
with pytest.raises(ValueError, match="invalid MAC"):
|
|
||||||
decrypt(tampered, conv_key)
|
|
||||||
|
|
||||||
def test_empty_plaintext_rejected(self):
|
|
||||||
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
|
|
||||||
with pytest.raises(ValueError, match="invalid plaintext length"):
|
|
||||||
encrypt("", conv_key)
|
|
||||||
|
|
||||||
def test_unknown_version_rejected(self):
|
|
||||||
with pytest.raises(ValueError, match="unknown version"):
|
|
||||||
decrypt("#invalid", bytes(32))
|
|
||||||
|
|
||||||
def test_short_payload_rejected(self):
|
|
||||||
with pytest.raises(ValueError, match="invalid payload size"):
|
|
||||||
decrypt("AAAA", bytes(32))
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
"""Tests for NIP-59 gift wrap protocol."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
|
|
||||||
import coincurve
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
SENDER_PRIV, SENDER_PUB = _generate_keypair()
|
|
||||||
RECIPIENT_PRIV, RECIPIENT_PUB = _generate_keypair()
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
def test_kind_13_with_empty_tags(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
|
|
||||||
assert seal.kind == 13
|
|
||||||
assert seal.tags == []
|
|
||||||
assert seal.pubkey == SENDER_PUB
|
|
||||||
|
|
||||||
def test_is_signed(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
|
|
||||||
assert seal.sig is not None
|
|
||||||
assert len(seal.sig) == 128 # 64 bytes hex
|
|
||||||
|
|
||||||
def test_content_is_encrypted(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
|
|
||||||
# Content should be base64 NIP-44 payload, not plaintext JSON
|
|
||||||
assert "hello" not in seal.content
|
|
||||||
|
|
||||||
def test_timestamp_is_randomized(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = create_seal(rumor, SENDER_PRIV, 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:
|
|
||||||
def test_kind_1059_with_p_tag(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
|
|
||||||
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
|
|
||||||
assert wrap.kind == 1059
|
|
||||||
assert ["p", RECIPIENT_PUB] in wrap.tags
|
|
||||||
|
|
||||||
def test_uses_ephemeral_key(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = create_seal(rumor, SENDER_PRIV, 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
|
|
||||||
|
|
||||||
def test_different_wraps_have_different_ephemeral_keys(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
|
|
||||||
wrap1 = create_gift_wrap(seal, RECIPIENT_PUB)
|
|
||||||
wrap2 = create_gift_wrap(seal, RECIPIENT_PUB)
|
|
||||||
assert wrap1.pubkey != wrap2.pubkey
|
|
||||||
|
|
||||||
|
|
||||||
class TestUnwrap:
|
|
||||||
def test_unwrap_gift_wrap_returns_seal(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello")
|
|
||||||
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
|
|
||||||
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
|
|
||||||
|
|
||||||
recovered_seal = unwrap_gift_wrap(wrap, RECIPIENT_PRIV)
|
|
||||||
assert recovered_seal.kind == 13
|
|
||||||
assert recovered_seal.pubkey == SENDER_PUB
|
|
||||||
|
|
||||||
def test_unseal_returns_rumor(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "hello world")
|
|
||||||
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
|
|
||||||
|
|
||||||
recovered_rumor = unseal(seal, RECIPIENT_PRIV)
|
|
||||||
assert recovered_rumor.content == "hello world"
|
|
||||||
assert recovered_rumor.pubkey == SENDER_PUB
|
|
||||||
assert recovered_rumor.kind == 14
|
|
||||||
|
|
||||||
def test_wrong_key_fails(self):
|
|
||||||
rumor = create_rumor(SENDER_PUB, "secret")
|
|
||||||
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
|
|
||||||
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
|
|
||||||
|
|
||||||
wrong_priv, _ = _generate_keypair()
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
unwrap_message(wrap, wrong_priv)
|
|
||||||
|
|
||||||
|
|
||||||
class TestFullRoundTrip:
|
|
||||||
def test_wrap_unwrap_message(self):
|
|
||||||
content = "Are you going to the party tonight?"
|
|
||||||
wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
|
|
||||||
|
|
||||||
assert wrap.kind == 1059
|
|
||||||
assert ["p", RECIPIENT_PUB] in wrap.tags
|
|
||||||
|
|
||||||
rumor = unwrap_message(wrap, RECIPIENT_PRIV)
|
|
||||||
assert rumor.content == content
|
|
||||||
assert rumor.pubkey == SENDER_PUB
|
|
||||||
assert rumor.kind == 14
|
|
||||||
assert rumor.sig is None
|
|
||||||
|
|
||||||
def test_wrap_with_custom_kind_and_tags(self):
|
|
||||||
tags = [["p", RECIPIENT_PUB], ["subject", "test"]]
|
|
||||||
wrap = wrap_message(
|
|
||||||
"order data",
|
|
||||||
SENDER_PRIV,
|
|
||||||
SENDER_PUB,
|
|
||||||
RECIPIENT_PUB,
|
|
||||||
kind=14,
|
|
||||||
tags=tags,
|
|
||||||
)
|
|
||||||
|
|
||||||
rumor = unwrap_message(wrap, RECIPIENT_PRIV)
|
|
||||||
assert rumor.content == "order data"
|
|
||||||
assert rumor.kind == 14
|
|
||||||
assert ["subject", "test"] in rumor.tags
|
|
||||||
|
|
||||||
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 = wrap_message(content, SENDER_PRIV, SENDER_PUB, SENDER_PUB)
|
|
||||||
|
|
||||||
rumor = unwrap_message(wrap, SENDER_PRIV)
|
|
||||||
assert rumor.content == content
|
|
||||||
assert rumor.pubkey == SENDER_PUB
|
|
||||||
|
|
||||||
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 = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
|
|
||||||
|
|
||||||
rumor = unwrap_message(wrap, RECIPIENT_PRIV)
|
|
||||||
recovered_order = json.loads(rumor.content)
|
|
||||||
assert recovered_order == order
|
|
||||||
|
|
||||||
def test_unicode_content(self):
|
|
||||||
content = "Payment received! \u2705 Your order is being processed \U0001f4e6"
|
|
||||||
wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
|
|
||||||
rumor = unwrap_message(wrap, RECIPIENT_PRIV)
|
|
||||||
assert rumor.content == content
|
|
||||||
119
views_api.py
119
views_api.py
|
|
@ -1,18 +1,15 @@
|
||||||
import json
|
import json
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from lnbits.core.crud import get_account, update_account
|
from lnbits.core.models import WalletTypeInfo
|
||||||
from lnbits.core.services import websocket_updater
|
from lnbits.core.services import websocket_updater
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
from lnbits.utils.exchange_rates import currencies
|
from lnbits.utils.exchange_rates import currencies
|
||||||
from lnbits.utils.nostr import generate_keypair
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from . import nostr_client, nostrmarket_ext
|
from . import nostr_client, nostrmarket_ext
|
||||||
|
|
@ -61,7 +58,6 @@ from .crud import (
|
||||||
)
|
)
|
||||||
from .helpers import normalize_public_key
|
from .helpers import normalize_public_key
|
||||||
from .models import (
|
from .models import (
|
||||||
CreateMerchantRequest,
|
|
||||||
Customer,
|
Customer,
|
||||||
DirectMessage,
|
DirectMessage,
|
||||||
DirectMessageType,
|
DirectMessageType,
|
||||||
|
|
@ -84,7 +80,6 @@ from .services import (
|
||||||
create_or_update_order_from_dm,
|
create_or_update_order_from_dm,
|
||||||
reply_to_structured_dm,
|
reply_to_structured_dm,
|
||||||
resubscribe_to_all_merchants,
|
resubscribe_to_all_merchants,
|
||||||
send_dm,
|
|
||||||
sign_and_send_to_nostr,
|
sign_and_send_to_nostr,
|
||||||
subscribe_to_all_merchants,
|
subscribe_to_all_merchants,
|
||||||
update_merchant_to_nostr,
|
update_merchant_to_nostr,
|
||||||
|
|
@ -95,48 +90,15 @@ from .services import (
|
||||||
|
|
||||||
@nostrmarket_ext.post("/api/v1/merchant")
|
@nostrmarket_ext.post("/api/v1/merchant")
|
||||||
async def api_create_merchant(
|
async def api_create_merchant(
|
||||||
data: CreateMerchantRequest,
|
data: PartialMerchant,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Merchant:
|
) -> Merchant:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if merchant already exists for this user
|
merchant = await get_merchant_by_pubkey(data.public_key)
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
assert merchant is None, "A merchant already uses this public key"
|
||||||
assert merchant is None, "A merchant already exists for this user"
|
|
||||||
|
|
||||||
# Get user's account to access their Nostr keypairs
|
merchant = await create_merchant(wallet.wallet.user, data)
|
||||||
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(
|
await create_zone(
|
||||||
merchant.id,
|
merchant.id,
|
||||||
|
|
@ -151,7 +113,7 @@ async def api_create_merchant(
|
||||||
|
|
||||||
await resubscribe_to_all_merchants()
|
await resubscribe_to_all_merchants()
|
||||||
|
|
||||||
await nostr_client.merchant_temp_subscription(public_key)
|
await nostr_client.merchant_temp_subscription(data.public_key)
|
||||||
|
|
||||||
return merchant
|
return merchant
|
||||||
except AssertionError as ex:
|
except AssertionError as ex:
|
||||||
|
|
@ -170,7 +132,7 @@ async def api_create_merchant(
|
||||||
@nostrmarket_ext.get("/api/v1/merchant")
|
@nostrmarket_ext.get("/api/v1/merchant")
|
||||||
async def api_get_merchant(
|
async def api_get_merchant(
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
) -> Optional[Merchant]:
|
) -> Merchant | None:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
|
|
@ -367,7 +329,7 @@ async def api_delete_merchant_on_nostr(
|
||||||
@nostrmarket_ext.get("/api/v1/zone")
|
@nostrmarket_ext.get("/api/v1/zone")
|
||||||
async def api_get_zones(
|
async def api_get_zones(
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
) -> List[Zone]:
|
) -> list[Zone]:
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
assert merchant, "Merchant cannot be found"
|
assert merchant, "Merchant cannot be found"
|
||||||
|
|
@ -567,7 +529,7 @@ async def api_get_stall(
|
||||||
|
|
||||||
@nostrmarket_ext.get("/api/v1/stall")
|
@nostrmarket_ext.get("/api/v1/stall")
|
||||||
async def api_get_stalls(
|
async def api_get_stalls(
|
||||||
pending: Optional[bool] = False,
|
pending: bool | None = False,
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
|
@ -591,7 +553,7 @@ async def api_get_stalls(
|
||||||
@nostrmarket_ext.get("/api/v1/stall/product/{stall_id}")
|
@nostrmarket_ext.get("/api/v1/stall/product/{stall_id}")
|
||||||
async def api_get_stall_products(
|
async def api_get_stall_products(
|
||||||
stall_id: str,
|
stall_id: str,
|
||||||
pending: Optional[bool] = False,
|
pending: bool | None = False,
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
|
@ -615,9 +577,9 @@ async def api_get_stall_products(
|
||||||
@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}")
|
@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}")
|
||||||
async def api_get_stall_orders(
|
async def api_get_stall_orders(
|
||||||
stall_id: str,
|
stall_id: str,
|
||||||
paid: Optional[bool] = None,
|
paid: bool | None = None,
|
||||||
shipped: Optional[bool] = None,
|
shipped: bool | None = None,
|
||||||
pubkey: Optional[str] = None,
|
pubkey: str | None = None,
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
|
@ -751,7 +713,7 @@ async def api_update_product(
|
||||||
async def api_get_product(
|
async def api_get_product(
|
||||||
product_id: str,
|
product_id: str,
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
) -> Optional[Product]:
|
) -> Product | None:
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
assert merchant, "Merchant cannot be found"
|
assert merchant, "Merchant cannot be found"
|
||||||
|
|
@ -836,9 +798,9 @@ async def api_get_order(
|
||||||
|
|
||||||
@nostrmarket_ext.get("/api/v1/order")
|
@nostrmarket_ext.get("/api/v1/order")
|
||||||
async def api_get_orders(
|
async def api_get_orders(
|
||||||
paid: Optional[bool] = None,
|
paid: bool | None = None,
|
||||||
shipped: Optional[bool] = None,
|
shipped: bool | None = None,
|
||||||
pubkey: Optional[str] = None,
|
pubkey: str | None = None,
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
|
@ -882,11 +844,27 @@ async def api_update_order_status(
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
await send_dm(
|
dm_event = merchant.build_dm_event(dm_content, order.public_key)
|
||||||
merchant,
|
|
||||||
order.public_key,
|
dm = PartialDirectMessage(
|
||||||
DirectMessageType.ORDER_PAID_OR_SHIPPED.value,
|
event_id=dm_event.id,
|
||||||
dm_content,
|
event_created_at=dm_event.created_at,
|
||||||
|
message=dm_content,
|
||||||
|
public_key=order.public_key,
|
||||||
|
type=DirectMessageType.ORDER_PAID_OR_SHIPPED.value,
|
||||||
|
)
|
||||||
|
await create_direct_message(merchant.id, dm)
|
||||||
|
|
||||||
|
await nostr_client.publish_nostr_event(dm_event)
|
||||||
|
await websocket_updater(
|
||||||
|
merchant.id,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": f"dm:{dm.type}",
|
||||||
|
"customerPubkey": order.public_key,
|
||||||
|
"dm": dm.dict(),
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return order
|
return order
|
||||||
|
|
@ -908,7 +886,7 @@ async def api_update_order_status(
|
||||||
async def api_restore_order(
|
async def api_restore_order(
|
||||||
event_id: str,
|
event_id: str,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Optional[Order]:
|
) -> Order | None:
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
assert merchant, "Merchant cannot be found"
|
assert merchant, "Merchant cannot be found"
|
||||||
|
|
@ -1035,7 +1013,7 @@ async def api_reissue_order_invoice(
|
||||||
@nostrmarket_ext.get("/api/v1/message/{public_key}")
|
@nostrmarket_ext.get("/api/v1/message/{public_key}")
|
||||||
async def api_get_messages(
|
async def api_get_messages(
|
||||||
public_key: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
public_key: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||||
) -> List[DirectMessage]:
|
) -> list[DirectMessage]:
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
assert merchant, "Merchant cannot be found"
|
assert merchant, "Merchant cannot be found"
|
||||||
|
|
@ -1064,13 +1042,14 @@ async def api_create_message(
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
assert merchant, "Merchant cannot be found"
|
assert merchant, "Merchant cannot be found"
|
||||||
|
|
||||||
dm_reply = await send_dm(
|
dm_event = merchant.build_dm_event(data.message, data.public_key)
|
||||||
merchant,
|
data.event_id = dm_event.id
|
||||||
data.public_key,
|
data.event_created_at = dm_event.created_at
|
||||||
data.type,
|
|
||||||
data.message,
|
dm = await create_direct_message(merchant.id, data)
|
||||||
)
|
await nostr_client.publish_nostr_event(dm_event)
|
||||||
return dm_reply
|
|
||||||
|
return dm
|
||||||
except AssertionError as ex:
|
except AssertionError as ex:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
|
@ -1090,7 +1069,7 @@ async def api_create_message(
|
||||||
@nostrmarket_ext.get("/api/v1/customer")
|
@nostrmarket_ext.get("/api/v1/customer")
|
||||||
async def api_get_customers(
|
async def api_get_customers(
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
) -> List[Customer]:
|
) -> list[Customer]:
|
||||||
try:
|
try:
|
||||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||||
assert merchant, "Merchant cannot be found"
|
assert merchant, "Merchant cannot be found"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue