Skip to content

Commit

Permalink
Buy identifiers (#9)
Browse files Browse the repository at this point in the history
* feat: improve messages

* feat: extra check when activating addrees

* feat: introduce reimbursement

* fix: missing await

* fix: update address for reimbursment

* feat: show reimburse qr code

* feat: nicer address in table

* fix: sort by reimburse amount

* refactor: extract `get_reimburse_wallet_id`

* feat: add address edit button

* feat: add relays to addresses

* feat: improvements based on UI testing

* feat: take into account the number of years

* feat: get_valid_addresses_for_owner

* feat: generate invoice only if `pubkey` pressent

* feat: delete address

* fix: use address years

* feat: add expires_at and validate ws url

* chore: code clean-up

* fix: only return the searched identifier

* fix: do not raise if payment not found

* chore: re-order

* refactor: revisit endpoints

* refactor: re-order functons

* fix: make endpoint consistent
  • Loading branch information
motorina0 authored Jul 19, 2024
1 parent a1e40e6 commit 884d8bf
Show file tree
Hide file tree
Showing 10 changed files with 843 additions and 210 deletions.
98 changes: 82 additions & 16 deletions crud.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import datetime
import json
from typing import List, Optional, Union
from typing import Any, List, Optional, Union

from lnbits.db import Database, Filters, Page
from lnbits.helpers import urlsafe_short_hash

from .helpers import normalize_identifier
from .models import (
Address,
AddressConfig,
AddressFilters,
CreateAddressData,
CreateDomainData,
Expand Down Expand Up @@ -77,11 +79,14 @@ async def get_address(domain_id: str, address_id: str) -> Optional[Address]:
return Address.from_row(row) if row else None


async def get_address_by_local_part(
async def get_active_address_by_local_part(
domain_id: str, local_part: str
) -> Optional[Address]:
row = await db.fetchone(
"SELECT * FROM nostrnip5.addresses WHERE domain_id = ? AND local_part = ?",
"""
SELECT * FROM nostrnip5.addresses
WHERE active = true AND domain_id = ? AND local_part = ?
""",
(
domain_id,
normalize_identifier(local_part),
Expand Down Expand Up @@ -112,7 +117,11 @@ async def get_address_for_owner(

async def get_addresses_for_owner(owner_id: str) -> List[Address]:
rows = await db.fetchall(
"SELECT * FROM nostrnip5.addresses WHERE owner_id = ?", (owner_id,)
"""
SELECT * FROM nostrnip5.addresses WHERE owner_id = ?
ORDER BY time DESC
""",
(owner_id,),
)

return [Address.from_row(row) for row in rows]
Expand Down Expand Up @@ -159,15 +168,19 @@ async def get_all_addresses_paginated(
)


async def activate_address(domain_id: str, address_id: str) -> Address:
async def activate_domain_address(
domain_id: str, address_id: str, config: AddressConfig
) -> Address:
extra = json.dumps(config, default=lambda o: o.__dict__)
await db.execute(
"""
UPDATE nostrnip5.addresses
SET active = true
SET active = true, extra = ?
WHERE domain_id = ?
AND id = ?
""",
(
extra,
domain_id,
address_id,
),
Expand Down Expand Up @@ -198,6 +211,46 @@ async def rotate_address(domain_id: str, address_id: str, pubkey: str) -> Addres
return address


async def update_address(domain_id: str, address_id: str, **kwargs) -> Address:
set_clause: List[str] = []
set_variables: List[Any] = []
valid_keys = [k for k in Address.__fields__.keys() if not k.startswith("_")]
for key, value in kwargs.items():
if key not in valid_keys:
continue
if key == "config":
config = value or AddressConfig()
extra = json.dumps(config, default=lambda o: o.__dict__)
set_clause.append("extra = ?")
set_variables.append(extra)

expires_at = datetime.datetime.now() + datetime.timedelta(
days=365 * config.years
)
set_clause.append("expires_at = ?")
set_variables.append(expires_at)
else:
set_clause.append(f"{key} = ?")
set_variables.append(value)

set_variables.append(domain_id)
set_variables.append(address_id)

await db.execute(
f"""
UPDATE nostrnip5.addresses
SET {', '.join(set_clause)}
WHERE domain_id = ?
AND id = ?
""",
tuple(set_variables),
)

address = await get_address(domain_id, address_id)
assert address, "Newly updated address couldn't be retrieved"
return address


async def delete_domain(domain_id: str, wallet_id: str) -> bool:
domain = await get_domain(domain_id, wallet_id)
if not domain:
Expand All @@ -219,28 +272,39 @@ async def delete_domain(domain_id: str, wallet_id: str) -> bool:
return True


async def delete_address(domain_id, address_id):
async def delete_address(domain_id, address_id, owner_id):
await db.execute(
"""
DELETE FROM nostrnip5.addresses WHERE domain_id = ? AND id = ?
DELETE FROM nostrnip5.addresses
WHERE domain_id = ? AND id = ? AND owner_id = ?
""",
(
domain_id,
address_id,
),
(domain_id, address_id, owner_id),
)


async def delete_address_by_id(domain_id, address_id):
await db.execute(
"""
DELETE FROM nostrnip5.addresses
WHERE domain_id = ? AND id = ?
""",
(domain_id, address_id),
)


async def create_address_internal(
data: CreateAddressData, owner_id: Optional[str] = None
data: CreateAddressData,
owner_id: Optional[str] = None,
config: Optional[AddressConfig] = None,
) -> Address:
address_id = urlsafe_short_hash()

extra = json.dumps(config or AddressConfig(), default=lambda o: o.__dict__)
expires_at = datetime.datetime.now() + datetime.timedelta(days=365 * data.years)
await db.execute(
"""
INSERT INTO nostrnip5.addresses
(id, domain_id, owner_id, local_part, pubkey, active)
VALUES (?, ?, ?, ?, ?, ?)
(id, domain_id, owner_id, local_part, pubkey, active, extra, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
address_id,
Expand All @@ -249,6 +313,8 @@ async def create_address_internal(
normalize_identifier(data.local_part),
data.pubkey,
False,
extra,
expires_at,
),
)

Expand Down
19 changes: 17 additions & 2 deletions helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from hashlib import sha256
from http import HTTPStatus
from typing import Optional
from urllib.parse import urlparse

from bech32 import bech32_decode, convertbits
from loguru import logger
Expand Down Expand Up @@ -39,9 +40,13 @@ def validate_pub_key(pubkey: str):
decoded_data = convertbits(data, 5, 8, False)
if decoded_data:
pubkey = bytes(decoded_data).hex()
try:
_hex = bytes.fromhex(pubkey)
except Exception as exc:
raise ValueError("Pubkey must be in npub or hex format.") from exc

if len(bytes.fromhex(pubkey)) != 32:
raise ValueError("Pubkey must be in npub or hex format.")
if len(_hex) != 32:
raise ValueError("Pubkey length incorrect.")

return pubkey

Expand All @@ -57,6 +62,16 @@ def validate_local_part(local_part: str):
)


def is_ws_url(url):
try:
result = urlparse(url)
if not all([result.scheme, result.netloc]):
return False
return result.scheme in ["ws", "wss"]
except ValueError:
return False


def owner_id_from_user_id(user_id: Optional[str] = None) -> str:
return sha256((user_id or "").encode("utf-8")).hexdigest()

Expand Down
14 changes: 13 additions & 1 deletion migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ async def m002_add_owner_id_to_addresess(db):

async def m003_add_cost_extra_column_to_domains(db):
"""
Adds cost_extra column to addresses.
Adds cost_extra column to domains.
"""
await db.execute("ALTER TABLE nostrnip5.domains ADD COLUMN cost_extra TEXT")

Expand Down Expand Up @@ -93,3 +93,15 @@ async def m006_make_amount_type_real(db):
)

await db.execute("ALTER TABLE nostrnip5.domains DROP COLUMN amount")


async def m007_add_cost_extra_column_to_addresses(db):
"""
Adds extra, expires_at and reimburse_amount columns to addresses.
"""
await db.execute("ALTER TABLE nostrnip5.addresses ADD COLUMN extra TEXT")
await db.execute("ALTER TABLE nostrnip5.addresses ADD COLUMN expires_at TIMESTAMP")
await db.execute(
"ALTER TABLE nostrnip5.addresses ADD COLUMN "
"reimburse_amount REAL NOT NULL DEFAULT 0"
)
64 changes: 55 additions & 9 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from lnbits.db import FilterModel, FromRowModel
from pydantic import BaseModel

from .helpers import format_amount, normalize_identifier
from .helpers import format_amount, is_ws_url, normalize_identifier


class CustomCost(BaseModel):
Expand All @@ -19,13 +19,29 @@ class RotateAddressData(BaseModel):
pubkey: str


class UpdateAddressData(BaseModel):
pubkey: Optional[str] = None
relays: Optional[List[str]] = None

def validate_relays_urls(self):
if not self.relays:
return
for r in self.relays:
if not is_ws_url(r):
raise ValueError(f"Relay '{r}' is not valid!")


class CreateAddressData(BaseModel):
domain_id: str
local_part: str
pubkey: str
pubkey: str = ""
years: int = 1
relays: Optional[List[str]] = None
create_invoice: bool = False


class DomainCostConfig(BaseModel):
max_years: int = 1
enable_custom_cost: bool = False
char_count_cost: List[CustomCost] = []
rank_cost: List[CustomCost] = []
Expand Down Expand Up @@ -76,31 +92,37 @@ class Domain(PublicDomain):
time: int

def price_for_identifier(
self, identifier: str, rank: Optional[int] = None
self, identifier: str, years: int, rank: Optional[int] = None
) -> Tuple[float, str]:
assert (
1 <= years <= self.cost_config.max_years
), f"Number of years must be between '1' and '{self.cost_config.max_years}'."

identifier = normalize_identifier(identifier)
max_amount = self.cost
reason = ""
if not self.cost_config.enable_custom_cost:
return max_amount, reason
return max_amount * years, reason

for char_cost in self.cost_config.char_count_cost:
if len(identifier) <= char_cost.bracket and max_amount < char_cost.amount:
max_amount = char_cost.amount
reason = f"{len(identifier)} characters"

if not rank:
return max_amount, reason
return max_amount * years, reason

for rank_cost in self.cost_config.rank_cost:
if rank <= rank_cost.bracket and max_amount < rank_cost.amount:
max_amount = rank_cost.amount
reason = f"Top {rank_cost.bracket} identifier"

return max_amount, reason
return max_amount * years, reason

def public_data(self):
return PublicDomain(**dict(self))
data = dict(PublicDomain(**dict(self)))
data["max_years"] = self.cost_config.max_years
return data

@classmethod
def from_row(cls, row: Row) -> "Domain":
Expand All @@ -111,6 +133,18 @@ def from_row(cls, row: Row) -> "Domain":
return domain


class AddressConfig(BaseModel):
currency: Optional[str] = None
price: Optional[float] = None
price_in_sats: Optional[float] = None
payment_hash: Optional[str] = None
reimburse_payment_hash: Optional[str] = None
activated_by_owner: bool = False
years: int = 1
max_years: int = 1
relays: List[str] = []


class Address(FromRowModel):
id: str
owner_id: Optional[str] = None
Expand All @@ -119,17 +153,28 @@ class Address(FromRowModel):
pubkey: str
active: bool
time: int
reimburse_amount: int = 0
expires_at: Optional[float]

config: AddressConfig = AddressConfig()

@property
def has_pubkey(self):
return self.pubkey != ""

@classmethod
def from_row(cls, row: Row) -> "Address":
return cls(**dict(row))
address = cls(**dict(row))
if row["extra"]:
address.config = AddressConfig(**json.loads(row["extra"]))
return address


class AddressStatus(BaseModel):
identifier: str
available: bool = False
reserved: bool = False
price: Optional[float] = None
price_in_sats: Optional[float] = None
price_reason: Optional[str] = None
currency: Optional[str] = None

Expand All @@ -144,6 +189,7 @@ def price_formatted(self) -> str:
class AddressFilters(FilterModel):
domain_id: str
local_part: str
reimburse_amount: str
pubkey: str
active: bool
time: int
Expand Down
Loading

0 comments on commit 884d8bf

Please sign in to comment.