Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Buy identifiers #9

Merged
merged 25 commits into from
Jul 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
70b706e
feat: improve messages
motorina0 Jul 8, 2024
603ddcb
feat: extra check when activating addrees
motorina0 Jul 8, 2024
a16eece
feat: introduce reimbursement
motorina0 Jul 8, 2024
80e704e
fix: missing await
motorina0 Jul 8, 2024
6c0c5e8
fix: update address for reimbursment
motorina0 Jul 8, 2024
b5b0746
feat: show reimburse qr code
motorina0 Jul 9, 2024
55ee658
feat: nicer address in table
motorina0 Jul 9, 2024
2e4968f
fix: sort by reimburse amount
motorina0 Jul 9, 2024
9b3c8ca
refactor: extract `get_reimburse_wallet_id`
motorina0 Jul 11, 2024
8f3ee6e
feat: add address edit button
motorina0 Jul 11, 2024
305d63b
feat: add relays to addresses
motorina0 Jul 11, 2024
9031409
feat: improvements based on UI testing
motorina0 Jul 12, 2024
4603de0
feat: take into account the number of years
motorina0 Jul 15, 2024
e2610ae
feat: get_valid_addresses_for_owner
motorina0 Jul 15, 2024
7677d3b
feat: generate invoice only if `pubkey` pressent
motorina0 Jul 16, 2024
139e186
feat: delete address
motorina0 Jul 16, 2024
f60a0ba
fix: use address years
motorina0 Jul 16, 2024
23ade3d
feat: add expires_at and validate ws url
motorina0 Jul 17, 2024
ca05a36
chore: code clean-up
motorina0 Jul 17, 2024
3f57465
fix: only return the searched identifier
motorina0 Jul 17, 2024
ef01e8c
fix: do not raise if payment not found
motorina0 Jul 18, 2024
30261eb
chore: re-order
motorina0 Jul 18, 2024
447357e
refactor: revisit endpoints
motorina0 Jul 18, 2024
b6729fa
refactor: re-order functons
motorina0 Jul 18, 2024
57aaa11
fix: make endpoint consistent
motorina0 Jul 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading