From 70b706ed92937d2a956b2d734db0d27eee4b00eb Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 8 Jul 2024 10:25:19 +0300 Subject: [PATCH 01/25] feat: improve messages --- services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services.py b/services.py index 3c28961..2505e7e 100644 --- a/services.py +++ b/services.py @@ -104,8 +104,8 @@ async def create_address( identifier_status = await get_identifier_status(domain, identifier) - assert identifier_status.available, "Identifier not available." - assert identifier_status.price, f"Cannot compute price for {identifier}" + assert identifier_status.available, f"Identifier '{identifier}' not available." + assert identifier_status.price, f"Cannot compute price for '{identifier}'." owner_id = owner_id_from_user_id(user_id) existing_address = await get_address_for_owner(owner_id, domain.id, identifier) From 603ddcb9dc038483fbdc72e961ba97ec5cc4452b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 8 Jul 2024 11:45:26 +0300 Subject: [PATCH 02/25] feat: extra check when activating addrees --- crud.py | 20 ++++++++++++++------ migrations.py | 9 ++++++++- models.py | 13 ++++++++++++- services.py | 22 ++++++++++++++++++++++ tasks.py | 8 ++------ views_api.py | 2 +- 6 files changed, 59 insertions(+), 15 deletions(-) diff --git a/crud.py b/crud.py index ec85de7..7cbdd9d 100644 --- a/crud.py +++ b/crud.py @@ -7,6 +7,7 @@ from .helpers import normalize_identifier from .models import ( Address, + AddressConfig, AddressFilters, CreateAddressData, CreateDomainData, @@ -159,15 +160,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, ), @@ -232,15 +237,17 @@ async def delete_address(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__) 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) + VALUES (?, ?, ?, ?, ?, ?, ?) """, ( address_id, @@ -249,6 +256,7 @@ async def create_address_internal( normalize_identifier(data.local_part), data.pubkey, False, + extra, ), ) diff --git a/migrations.py b/migrations.py index 5626371..26d6e94 100644 --- a/migrations.py +++ b/migrations.py @@ -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") @@ -93,3 +93,10 @@ 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 column to addresses. + """ + await db.execute("ALTER TABLE nostrnip5.addresses ADD COLUMN extra TEXT") diff --git a/models.py b/models.py index 57cee14..d8dfedd 100644 --- a/models.py +++ b/models.py @@ -111,6 +111,12 @@ def from_row(cls, row: Row) -> "Domain": return domain +class AddressConfig(BaseModel): + payment_hash: Optional[str] = None + activated_by_owner: bool = False + relays: List[str] = [] + + class Address(FromRowModel): id: str owner_id: Optional[str] = None @@ -120,9 +126,14 @@ class Address(FromRowModel): active: bool time: int + config: AddressConfig = AddressConfig() + @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): diff --git a/services.py b/services.py index 2505e7e..b770451 100644 --- a/services.py +++ b/services.py @@ -7,9 +7,11 @@ from loguru import logger from .crud import ( + activate_domain_address, create_address_internal, create_identifier_ranking, delete_inferior_ranking, + get_address, get_address_by_local_part, get_address_for_owner, get_all_addresses, @@ -123,6 +125,26 @@ async def create_address( return address, price_in_sats +async def activate_address( + domain_id: str, address_id: str, payment_hash: Optional[str] = None +) -> bool: + logger.info(f"Activating NOSTR NIP-05 '{address_id}' for {domain_id}") + try: + address = await get_address(domain_id, address_id) + assert address, f"Cannot find address '{address_id}' for {domain_id}." + assert not address.active, f"Address '{address_id}' already active." + + address.config.activated_by_owner = payment_hash is None + address.config.payment_hash = payment_hash + await activate_domain_address(domain_id, address_id, address.config) + + return True + except Exception as exc: + logger.warning(exc) + logger.info(f"Failed to acivate NOSTR NIP-05 '{address_id}' for {domain_id}.") + return False + + async def check_address_payment(domain_id: str, payment_hash: str) -> bool: payment = await get_standalone_payment(payment_hash, incoming=True) assert payment, "Payment does not exist." diff --git a/tasks.py b/tasks.py index b6bf018..1f04922 100644 --- a/tasks.py +++ b/tasks.py @@ -2,9 +2,8 @@ from lnbits.core.models import Payment from lnbits.tasks import register_invoice_listener -from loguru import logger -from .crud import activate_address +from .services import activate_address async def wait_for_paid_invoices(): @@ -24,9 +23,6 @@ async def on_invoice_paid(payment: Payment) -> None: address_id = payment.extra.get("address_id") if domain_id and address_id: - logger.info("Activating NOSTR NIP-05") - logger.info(domain_id) - logger.info(address_id) - await activate_address(domain_id, address_id) + await activate_address(domain_id, address_id, payment_hash=payment.payment_hash) return diff --git a/views_api.py b/views_api.py index f08f788..8c7af4e 100644 --- a/views_api.py +++ b/views_api.py @@ -19,7 +19,6 @@ from starlette.exceptions import HTTPException from .crud import ( - activate_address, create_domain_internal, create_settings, delete_address, @@ -52,6 +51,7 @@ RotateAddressData, ) from .services import ( + activate_address, check_address_payment, create_address, get_identifier_status, From a16eeceb2d420f12666856ae96219d3404734569 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 8 Jul 2024 16:11:40 +0300 Subject: [PATCH 03/25] feat: introduce reimbursement --- crud.py | 42 +++++++++++++++++++++++++++++++++++++--- migrations.py | 4 ++++ models.py | 3 ++- services.py | 30 +++++++++++++++++++++-------- tasks.py | 32 ++++++++++++++++++++++++++++--- views_api.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 15 deletions(-) diff --git a/crud.py b/crud.py index 7cbdd9d..d3f6584 100644 --- a/crud.py +++ b/crud.py @@ -1,5 +1,5 @@ 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 @@ -78,11 +78,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), @@ -203,6 +206,39 @@ 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 vars(Address) if not k.startswith("__")] + for key, value in kwargs.items(): + if key not in valid_keys: + continue + if key == "config": + extra = json.dumps(value or AddressConfig(), default=lambda o: o.__dict__) + set_clause.append("extra = ?") + set_variables.append(extra) + 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: diff --git a/migrations.py b/migrations.py index 26d6e94..9d15fa3 100644 --- a/migrations.py +++ b/migrations.py @@ -100,3 +100,7 @@ async def m007_add_cost_extra_column_to_addresses(db): Adds extra column to addresses. """ await db.execute("ALTER TABLE nostrnip5.addresses ADD COLUMN extra TEXT") + await db.execute( + "ALTER TABLE nostrnip5.addresses ADD COLUMN " + "reimburse_amount REAL NOT NULL DEFAULT 0" + ) diff --git a/models.py b/models.py index d8dfedd..e9f29f7 100644 --- a/models.py +++ b/models.py @@ -113,6 +113,7 @@ def from_row(cls, row: Row) -> "Domain": class AddressConfig(BaseModel): payment_hash: Optional[str] = None + reimburse_payment_hash: Optional[str] = None activated_by_owner: bool = False relays: List[str] = [] @@ -125,6 +126,7 @@ class Address(FromRowModel): pubkey: str active: bool time: int + reimburse_amount: int = 0 config: AddressConfig = AddressConfig() @@ -139,7 +141,6 @@ def from_row(cls, row: Row) -> "Address": class AddressStatus(BaseModel): identifier: str available: bool = False - reserved: bool = False price: Optional[float] = None price_reason: Optional[str] = None currency: Optional[str] = None diff --git a/services.py b/services.py index b770451..5b28cd1 100644 --- a/services.py +++ b/services.py @@ -2,6 +2,7 @@ import httpx from lnbits.core.crud import get_standalone_payment, get_user +from lnbits.core.models import Payment from lnbits.db import Filters, Page from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from loguru import logger @@ -11,8 +12,8 @@ create_address_internal, create_identifier_ranking, delete_inferior_ranking, + get_active_address_by_local_part, get_address, - get_address_by_local_part, get_address_for_owner, get_all_addresses, get_all_addresses_paginated, @@ -71,10 +72,9 @@ async def get_user_addresses_paginated( async def get_identifier_status(domain: Domain, identifier: str) -> AddressStatus: identifier = normalize_identifier(identifier) - address = await get_address_by_local_part(domain.id, identifier) - reserved = address is not None - if address and address.active: - return AddressStatus(identifier=identifier, available=False, reserved=reserved) + address = await get_active_address_by_local_part(domain.id, identifier) + if address: + return AddressStatus(identifier=identifier, available=False) rank = None if domain.cost_config.enable_custom_cost: @@ -82,14 +82,13 @@ async def get_identifier_status(domain: Domain, identifier: str) -> AddressStatu rank = identifier_ranking.rank if identifier_ranking else None if rank == 0: - return AddressStatus(identifier=identifier, available=False, reserved=True) + return AddressStatus(identifier=identifier, available=False) price, reason = domain.price_for_identifier(identifier, rank) return AddressStatus( identifier=identifier, available=True, - reserved=reserved, price=price, price_reason=reason, currency=domain.currency, @@ -132,7 +131,10 @@ async def activate_address( try: address = await get_address(domain_id, address_id) assert address, f"Cannot find address '{address_id}' for {domain_id}." - assert not address.active, f"Address '{address_id}' already active." + active_address = await get_active_address_by_local_part( + domain_id, address.local_part + ) + assert not active_address, f"Address '{address.local_part}' already active." address.config.activated_by_owner = payment_hash is None address.config.payment_hash = payment_hash @@ -162,6 +164,18 @@ async def check_address_payment(domain_id: str, payment_hash: str) -> bool: return status.success +async def reimburse_payment(payment: Payment): + reimburse_wallet_ids = payment.extra.get("reimburse_wallet_ids", []) + domain_id = payment.extra.get("domain_id") + address_id = payment.extra.get("address_id") + + if len(reimburse_wallet_ids) == 0: + logger.info( + f"Cannot reimburse failed activation for payment '{payment.payment_hash}'" + f"Info: domain ID ({domain_id}), address ID ({address_id})." + ) + + async def update_identifiers(identifiers: List[str], bucket: int): for identifier in identifiers: await update_identifier(identifier, bucket) diff --git a/tasks.py b/tasks.py index 1f04922..aa5bdaa 100644 --- a/tasks.py +++ b/tasks.py @@ -2,7 +2,9 @@ from lnbits.core.models import Payment from lnbits.tasks import register_invoice_listener +from loguru import logger +from .crud import get_address, update_address from .services import activate_address @@ -21,8 +23,32 @@ async def on_invoice_paid(payment: Payment) -> None: domain_id = payment.extra.get("domain_id") address_id = payment.extra.get("address_id") + action = payment.extra.get("action") - if domain_id and address_id: - await activate_address(domain_id, address_id, payment_hash=payment.payment_hash) + if not domain_id or not address_id or not action: + logger.info( + f"Cannot {action} for payment '{payment.payment_hash}'." + f"Missing domain ID ({domain_id}) or address ID ({address_id})." + ) + return + + address = await get_address(domain_id, address_id) + if not address: + logger.info( + f"Cannot find address for payment '{payment.payment_hash}'." + f"Missing domain ID ({domain_id}) or address ID ({address_id})." + ) + return - return + if action == "activate": + activated = await activate_address(domain_id, address_id, payment.payment_hash) + if not activated: + address.config.reimburse_payment_hash = payment.payment_hash + await update_address( + domain_id, + address_id, + reimburse_amount=payment.amount, + config=address.config, + ) + elif action == "reimburse": + await update_address(domain_id, address_id, reimburse_amount=0) diff --git a/views_api.py b/views_api.py index 8c7af4e..1681d76 100644 --- a/views_api.py +++ b/views_api.py @@ -3,6 +3,7 @@ import httpx from fastapi import APIRouter, Depends, Query, Request, Response +from lnbits.core.crud import get_standalone_payment, get_wallets from lnbits.core.models import User, WalletTypeInfo from lnbits.core.services import create_invoice from lnbits.db import Filters, Page @@ -23,6 +24,7 @@ create_settings, delete_address, delete_domain, + get_address, get_addresses, get_addresses_for_owner, get_domain, @@ -220,6 +222,52 @@ async def api_address_rotate( return True +@http_try_except +@nostrnip5_api_router.put( + "/api/v1/domain/{domain_id}/address/{address_id}/reimburse", + dependencies=[Depends(check_admin)], + status_code=HTTPStatus.CREATED, +) +async def api_address_reimburse( + domain_id: str, + adddress_id: str, +): + + # make sure the address belongs to the user + domain = await get_domain_by_id(domain_id) + assert domain, "Domain does not exist." + + address = await get_address(domain_id, adddress_id) + + assert address and address.domain_id == domain_id, "Domain ID missmatch" + payment_hash = address.config.reimburse_payment_hash + assert payment_hash, f"No payment hash found to reimburse '{address.id}'." + + payment = get_standalone_payment(checking_id_or_hash=payment_hash) + assert payment, f"No payment found to reimburse '{payment_hash}'." + wallet_id = payment.extra.get("reimburse_wallet_id") + assert wallet_id, f"No wallet found to reimburse payment {payment_hash}." + + payment_hash, payment_request = await create_invoice( + wallet_id=wallet_id, + amount=address.reimburse_amount, + memo=f"Reimbursement for NIP-05 for {address.local_part}@{domain.domain}", + extra={ + "tag": "nostrnip5", + "domain_id": domain_id, + "address_id": address.id, + "local_part": address.local_part, + "action": "reimburse", + }, + ) + + return { + "payment_hash": payment_hash, + "payment_request": payment_request, + "address_id": address.id, + } + + @http_try_except @nostrnip5_api_router.post( "/api/v1/domain/{domain_id}/address", status_code=HTTPStatus.CREATED @@ -237,6 +285,9 @@ async def api_address_create( assert address_data.domain_id == domain_id, "Domain ID missmatch" address, price_in_sats = await create_address(domain, address_data, user_id) + # in case the user pays, but the identifier is no longer available + wallet_id = await get_wallets(user_id)[0] if user_id else None + payment_hash, payment_request = await create_invoice( wallet_id=domain.wallet, amount=price_in_sats, @@ -245,6 +296,8 @@ async def api_address_create( "tag": "nostrnip5", "domain_id": domain_id, "address_id": address.id, + "action": "activate", + "reimburse_wallet_id": wallet_id, }, ) From 80e704eabd2cb4b808457e8cc5b4ad72f11517ef Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 8 Jul 2024 16:14:29 +0300 Subject: [PATCH 04/25] fix: missing await --- views_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/views_api.py b/views_api.py index 1681d76..fb19368 100644 --- a/views_api.py +++ b/views_api.py @@ -243,7 +243,7 @@ async def api_address_reimburse( payment_hash = address.config.reimburse_payment_hash assert payment_hash, f"No payment hash found to reimburse '{address.id}'." - payment = get_standalone_payment(checking_id_or_hash=payment_hash) + payment = await get_standalone_payment(checking_id_or_hash=payment_hash) assert payment, f"No payment found to reimburse '{payment_hash}'." wallet_id = payment.extra.get("reimburse_wallet_id") assert wallet_id, f"No wallet found to reimburse payment {payment_hash}." @@ -286,7 +286,7 @@ async def api_address_create( address, price_in_sats = await create_address(domain, address_data, user_id) # in case the user pays, but the identifier is no longer available - wallet_id = await get_wallets(user_id)[0] if user_id else None + wallet_id = (await get_wallets(user_id))[0] if user_id else None payment_hash, payment_request = await create_invoice( wallet_id=domain.wallet, From 6c0c5e8f14978744a3a26425311d0129ae9c14d3 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 8 Jul 2024 17:04:42 +0300 Subject: [PATCH 05/25] fix: update address for reimbursment --- crud.py | 6 +++--- views_api.py | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/crud.py b/crud.py index d3f6584..18f8e84 100644 --- a/crud.py +++ b/crud.py @@ -209,7 +209,7 @@ async def rotate_address(domain_id: str, address_id: str, pubkey: str) -> Addres 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 vars(Address) if not k.startswith("__")] + 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 @@ -221,8 +221,8 @@ async def update_address(domain_id: str, address_id: str, **kwargs) -> Address: set_clause.append(f"{key} = ?") set_variables.append(value) - set_variables.append(domain_id) - set_variables.append(address_id) + set_variables.append(domain_id) + set_variables.append(address_id) await db.execute( f""" diff --git a/views_api.py b/views_api.py index fb19368..8a9d2ba 100644 --- a/views_api.py +++ b/views_api.py @@ -32,6 +32,7 @@ get_identifier_ranking, get_settings, rotate_address, + update_address, update_domain_internal, update_identifier_ranking, ) @@ -223,27 +224,27 @@ async def api_address_rotate( @http_try_except -@nostrnip5_api_router.put( +@nostrnip5_api_router.get( "/api/v1/domain/{domain_id}/address/{address_id}/reimburse", dependencies=[Depends(check_admin)], status_code=HTTPStatus.CREATED, ) async def api_address_reimburse( domain_id: str, - adddress_id: str, + address_id: str, ): # make sure the address belongs to the user domain = await get_domain_by_id(domain_id) assert domain, "Domain does not exist." - address = await get_address(domain_id, adddress_id) + address = await get_address(domain_id, address_id) assert address and address.domain_id == domain_id, "Domain ID missmatch" payment_hash = address.config.reimburse_payment_hash assert payment_hash, f"No payment hash found to reimburse '{address.id}'." - payment = await get_standalone_payment(checking_id_or_hash=payment_hash) + payment = await get_standalone_payment(checking_id_or_hash=payment_hash, incoming=True) assert payment, f"No payment found to reimburse '{payment_hash}'." wallet_id = payment.extra.get("reimburse_wallet_id") assert wallet_id, f"No wallet found to reimburse payment {payment_hash}." @@ -286,7 +287,7 @@ async def api_address_create( address, price_in_sats = await create_address(domain, address_data, user_id) # in case the user pays, but the identifier is no longer available - wallet_id = (await get_wallets(user_id))[0] if user_id else None + wallet_id = (await get_wallets(user_id))[0].id if user_id else None payment_hash, payment_request = await create_invoice( wallet_id=domain.wallet, From b5b07460d384c46a852c4cf47d609d4f96c4810a Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 9 Jul 2024 11:32:55 +0300 Subject: [PATCH 06/25] feat: show reimburse qr code --- templates/nostrnip5/index.html | 89 ++++++++++++++++++++++++++++++++-- views_api.py | 7 +-- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/templates/nostrnip5/index.html b/templates/nostrnip5/index.html index 183baca..b954c13 100644 --- a/templates/nostrnip5/index.html +++ b/templates/nostrnip5/index.html @@ -177,9 +177,19 @@
Addresses
@click="deleteAddress(props.row.id)" > - - {{ col.value }} + + + + + + + + {% endraw %} @@ -239,10 +249,10 @@
@@ -580,6 +590,30 @@
Settings
+ + + + + + + + +
+ Copy Invoice +
+
{% endblock %} {% block scripts %} {{ window_vars(user) }}