feat: publish event on C_UD for stalls

This commit is contained in:
Vlad Stan 2023-03-02 10:14:11 +02:00
parent aba3706a71
commit 5972c44ad1
7 changed files with 305 additions and 40 deletions

47
crud.py
View file

@ -1,4 +1,5 @@
import json
import time
from typing import List, Optional
from lnbits.helpers import urlsafe_short_hash
@ -51,15 +52,7 @@ async def create_zone(user_id: str, data: PartialZone) -> Zone:
zone_id = urlsafe_short_hash()
await db.execute(
f"""
INSERT INTO nostrmarket.zones (
id,
user_id,
name,
currency,
cost,
regions
)
INSERT INTO nostrmarket.zones (id, user_id, name, currency, cost, regions)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
@ -112,6 +105,7 @@ async def delete_zone(zone_id: str) -> None:
async def create_stall(user_id: str, data: PartialStall) -> Stall:
stall_id = urlsafe_short_hash()
await db.execute(
f"""
INSERT INTO nostrmarket.stalls (user_id, id, wallet, name, currency, zones, meta)
@ -123,8 +117,10 @@ async def create_stall(user_id: str, data: PartialStall) -> Stall:
data.wallet,
data.name,
data.currency,
json.dumps(data.shipping_zones),
json.dumps(dict(data.config)),
json.dumps(
[z.dict() for z in data.shipping_zones]
), # todo: cost is float. should be int for sats
json.dumps(data.config.dict()),
),
)
@ -152,17 +148,32 @@ async def get_stalls(user_id: str) -> List[Stall]:
return [Stall.from_row(row) for row in rows]
async def update_stall(user_id: str, stall_id: str, **kwargs) -> Optional[Stall]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
async def update_stall(user_id: str, stall: Stall) -> Optional[Stall]:
await db.execute(
f"UPDATE market.stalls SET {q} WHERE user_id = ? AND id = ?",
(*kwargs.values(), user_id, stall_id),
f"""
UPDATE nostrmarket.stalls SET wallet = ?, name = ?, currency = ?, zones = ?, meta = ?
WHERE user_id = ? AND id = ?
""",
(
stall.wallet,
stall.name,
stall.currency,
json.dumps(
[z.dict() for z in stall.shipping_zones]
), # todo: cost is float. should be int for sats
json.dumps(stall.config.dict()),
user_id,
stall.id,
),
)
row = await db.fetchone(
"SELECT * FROM market.stalls WHERE user_id =? AND id = ?",
return await get_stall(user_id, stall.id)
async def delete_stall(user_id: str, stall_id: str) -> None:
await db.execute(
"DELETE FROM nostrmarket.stalls WHERE user_id =? AND id = ?",
(
user_id,
stall_id,
),
)
return Stall.from_row(row) if row else None

81
helpers.py Normal file
View file

@ -0,0 +1,81 @@
import base64
import json
import secrets
from typing import Optional
import secp256k1
from cffi import FFI
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
def get_shared_secret(privkey: str, pubkey: str):
point = secp256k1.PublicKey(bytes.fromhex("02" + pubkey), True)
return point.ecdh(bytes.fromhex(privkey), hashfn=copy_x)
def decrypt_message(encoded_message: str, encryption_key) -> str:
encoded_data = encoded_message.split("?iv=")
encoded_content, encoded_iv = encoded_data[0], encoded_data[1]
iv = base64.b64decode(encoded_iv)
cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv))
encrypted_content = base64.b64decode(encoded_content)
decryptor = cipher.decryptor()
decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize()
return unpadded_data.decode()
def encrypt_message(message: str, encryption_key, iv: Optional[bytes]) -> 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()
return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}"
def sign_message_hash(private_key: str, hash: bytes) -> str:
privkey = secp256k1.PrivateKey(bytes.fromhex(private_key))
sig = privkey.schnorr_sign(hash, None, raw=True)
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}'"
print("### test_decrypt_encrypt", encoded_message == ecrypted_msg)
ffi = FFI()
@ffi.callback(
"int (unsigned char *, const unsigned char *, const unsigned char *, void *)"
)
def copy_x(output, x32, y32, data):
ffi.memmove(output, x32, 32)
return 1
def is_json(string: str):
try:
json.loads(string)
except ValueError as e:
return False
return True

View file

@ -1,10 +1,13 @@
import json
import time
from sqlite3 import Row
from typing import List, Optional
from fastapi import Query
from pydantic import BaseModel
from .helpers import sign_message_hash
from .nostr.event import NostrEvent
######################################## MERCHANT ########################################
class MerchantConfig(BaseModel):
@ -20,6 +23,9 @@ class PartialMerchant(BaseModel):
class Merchant(PartialMerchant):
id: str
def sign_hash(self, hash: bytes) -> str:
return sign_message_hash(self.private_key, hash)
@classmethod
def from_row(cls, row: Row) -> "Merchant":
merchant = cls(**dict(row))
@ -49,24 +55,57 @@ class Zone(PartialZone):
class StallConfig(BaseModel):
"""Last published nostr event id for this Stall"""
event_id: Optional[str]
image_url: Optional[str]
fiat_base_multiplier: int = 1 # todo: reminder wht is this for?
description: Optional[str]
class PartialStall(BaseModel):
wallet: str
name: str
currency: str = "sat"
shipping_zones: List[str] = []
shipping_zones: List[PartialZone] = []
config: StallConfig = StallConfig()
class Stall(PartialStall):
id: str
def to_nostr_event(self, pubkey: str) -> NostrEvent:
content = {
"name": self.name,
"description": self.config.description,
"currency": self.currency,
"shipping": [dict(z) for z in self.shipping_zones],
}
event = NostrEvent(
pubkey=pubkey,
created_at=round(time.time()),
kind=30005,
tags=[["d", self.id]],
content=json.dumps(content, separators=(",", ":"), ensure_ascii=False),
)
event.id = event.event_id
return event
def to_nostr_delete_event(self, pubkey: str) -> NostrEvent:
delete_event = NostrEvent(
pubkey=pubkey,
created_at=round(time.time()),
kind=5,
tags=[["e", self.config.event_id]],
content="Stall deleted",
)
delete_event.id = delete_event.event_id
return delete_event
@classmethod
def from_row(cls, row: Row) -> "Stall":
stall = cls(**dict(row))
stall.config = StallConfig(**json.loads(row["meta"]))
stall.shipping_zones = json.loads(row["zones"])
stall.shipping_zones = [PartialZone(**z) for z in json.loads(row["zones"])]
return stall

57
nostr/event.py Normal file
View file

@ -0,0 +1,57 @@
import hashlib
import json
from typing import List, Optional
from pydantic import BaseModel
from secp256k1 import PublicKey
class NostrEvent(BaseModel):
id: str = ""
pubkey: str
created_at: int
kind: int
tags: List[List[str]] = []
content: str = ""
sig: Optional[str]
def serialize(self) -> List:
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
def serialize_json(self) -> str:
e = self.serialize()
return json.dumps(e, separators=(",", ":"), ensure_ascii=False)
@property
def event_id(self) -> str:
data = self.serialize_json()
id = hashlib.sha256(data.encode()).hexdigest()
return id
def check_signature(self):
event_id = self.event_id
if self.id != event_id:
raise ValueError(
f"Invalid event id. Expected: '{event_id}' got '{self.id}'"
)
try:
pub_key = PublicKey(bytes.fromhex("02" + self.pubkey), True)
except Exception:
raise ValueError(
f"Invalid public key: '{self.pubkey}' for event '{self.id}'"
)
valid_signature = pub_key.schnorr_verify(
bytes.fromhex(event_id), bytes.fromhex(self.sig), None, raw=True
)
if not valid_signature:
raise ValueError(f"Invalid signature: '{self.sig}' for event '{self.id}'")
def stringify(self) -> str:
return json.dumps(dict(self))
def tag_values(self, tag_name: str) -> List[str]:
return [t[1] for t in self.tags if t[0] == tag_name]
def has_tag_value(self, tag_name: str, tag_value: str) -> bool:
return tag_value in self.tag_values(tag_name)

21
nostr/nostr_client.py Normal file
View file

@ -0,0 +1,21 @@
import httpx
from loguru import logger
from lnbits.app import settings
from lnbits.helpers import url_for
from .event import NostrEvent
async def publish_nostr_event(e: NostrEvent):
url = url_for("/nostrclient/api/v1/publish", external=True)
data = dict(e)
print("### published", dict(data))
async with httpx.AsyncClient() as client:
try:
await client.post(
url,
json=data,
)
except Exception as ex:
logger.warning(ex)

View file

@ -30,7 +30,7 @@ async function stallList(path) {
name: this.stallDialog.data.name,
wallet: this.stallDialog.data.wallet,
currency: this.stallDialog.data.currency,
shipping_zones: this.stallDialog.data.shippingZones.map(z => z.id),
shipping_zones: this.stallDialog.data.shippingZones,
config: {}
})
},
@ -71,10 +71,14 @@ async function stallList(path) {
'/nostrmarket/api/v1/zone',
this.inkey
)
console.log('### zones', data)
this.zoneOptions = data.map(z => ({
id: z.id,
label: `${z.name} (${z.countries.join(', ')})`
...z,
label: z.name
? `${z.name} (${z.countries.join(', ')}})`
: z.countries.join(', ')
}))
console.log('### this.zoneOptions', this.zoneOptions)
} catch (error) {
LNbits.utils.notifyApiError(error)
}

View file

@ -1,3 +1,4 @@
import json
from http import HTTPStatus
from typing import List, Optional
@ -18,6 +19,7 @@ from .crud import (
create_merchant,
create_stall,
create_zone,
delete_stall,
delete_zone,
get_merchant_for_user,
get_stall,
@ -28,6 +30,7 @@ from .crud import (
update_zone,
)
from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone
from .nostr.nostr_client import publish_nostr_event
######################################## MERCHANT ########################################
@ -148,11 +151,23 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi
@nostrmarket_ext.post("/api/v1/stall")
async def api_create_stall(
data: PartialStall,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Stall:
try:
print("### stall", json.dumps(data.dict()))
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Cannot find merchat for stall"
stall = await create_stall(wallet.wallet.user, data=data)
return stall.dict()
event = stall.to_nostr_event(merchant.public_key)
event.sig = merchant.sign_hash(bytes.fromhex(event.id))
await publish_nostr_event(event)
stall.config.event_id = event.id
await update_stall(wallet.wallet.user, stall)
return stall
except Exception as ex:
logger.warning(ex)
raise HTTPException(
@ -164,18 +179,23 @@ async def api_create_stall(
@nostrmarket_ext.put("/api/v1/stall/{stall_id}")
async def api_update_stall(
data: Stall,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Stall:
try:
stall = await get_stall(wallet.wallet.user, data.id)
if not stall:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Stall does not exist.",
)
stall = await update_stall(wallet.wallet.user, data.id, **data.dict())
assert stall, "Cannot fetch updated stall"
return stall.dict()
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Cannot find merchat for stall"
event = data.to_nostr_event(merchant.public_key)
event.sig = merchant.sign_hash(bytes.fromhex(event.id))
data.config.event_id = event.id
# data.config.created_at =
stall = await update_stall(wallet.wallet.user, data)
assert stall, "Cannot update stall"
await publish_nostr_event(event)
return stall
except HTTPException as ex:
raise ex
except Exception as ex:
@ -207,7 +227,7 @@ async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_
@nostrmarket_ext.get("/api/v1/stall")
async def api_gey_stalls(wallet: WalletTypeInfo = Depends(get_key_type)):
async def api_get_stalls(wallet: WalletTypeInfo = Depends(get_key_type)):
try:
stalls = await get_stalls(wallet.wallet.user)
return stalls
@ -219,6 +239,38 @@ async def api_gey_stalls(wallet: WalletTypeInfo = Depends(get_key_type)):
)
@nostrmarket_ext.delete("/api/v1/stall/{stall_id}")
async def api_delete_stall(
stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
try:
stall = await get_stall(wallet.wallet.user, stall_id)
if not stall:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Stall does not exist.",
)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Cannot find merchat for stall"
await delete_stall(wallet.wallet.user, stall_id)
delete_event = stall.to_nostr_delete_event(merchant.public_key)
delete_event.sig = merchant.sign_hash(bytes.fromhex(delete_event.id))
await publish_nostr_event(delete_event)
except HTTPException as ex:
raise ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot delte stall",
)
######################################## OTHER ########################################