diff --git a/lnbits/core/views/public_api.py b/lnbits/core/views/public_api.py
index d9213d6c5..32abacb6e 100644
--- a/lnbits/core/views/public_api.py
+++ b/lnbits/core/views/public_api.py
@@ -1,7 +1,10 @@
import asyncio
import datetime
from http import HTTPStatus
+
from fastapi import HTTPException
+from starlette.requests import Request
+
from lnbits import bolt11
from .. import core_app
@@ -9,6 +12,13 @@ from ..crud import get_standalone_payment
from ..tasks import api_invoice_listeners
+@core_app.get("/.well-known/lnurlp/{username}")
+async def lnaddress(username: str, request: Request):
+ from lnbits.extensions.lnaddress.lnurl import lnurl_response
+ domain = request.client.host
+ return await lnurl_response(username, domain)
+
+
@core_app.get("/public/v1/payment/{payment_hash}")
async def api_public_payment_longpolling(payment_hash):
payment = await get_standalone_payment(payment_hash)
diff --git a/lnbits/extensions/lnaddress/README.md b/lnbits/extensions/lnaddress/README.md
new file mode 100644
index 000000000..d7e405033
--- /dev/null
+++ b/lnbits/extensions/lnaddress/README.md
@@ -0,0 +1,68 @@
+
Lightning Address
+Rent Lightning Addresses on your domain
+LNAddress extension allows for someone to rent users lightning addresses on their domain.
+
+The extension is muted by default on the .env file and needs the admin of the LNbits instance to meet a few requirements on the server.
+
+## Requirements
+
+- Free Cloudflare account
+- Cloudflare as a DNS server provider
+- Cloudflare TOKEN and Cloudflare zone-ID where the domain is parked
+
+The server must provide SSL/TLS certificates to domain owners. If using caddy, this can be easily achieved with the Caddyfife snippet:
+
+```
+:443 {
+ reverse_proxy localhost:5000
+
+ tls @example.com {
+ on_demand
+ }
+}
+```
+
+fill in with your email.
+
+Certbot is also a possibity.
+
+## Usage
+
+1. Before adding a domain, you need to add the domain to Cloudflare and get an API key and Secret key\
+ \
+ You can use the _Edit zone DNS_ template Cloudflare provides.\
+ \
+ Edit the template as you like, if only using one domain you can narrow the scope of the template\
+ 
+
+2. Back on LNbits, click "ADD DOMAIN"\
+ 
+
+3. Fill the form with the domain information\
+ 
+
+ - select your wallet - add your domain
+ - cloudflare keys
+ - an optional webhook to get notified
+ - the amount, in sats, you'll rent the addresses, per day
+
+4. Your domains will show up on the _Domains_ section\
+ \
+ On the left side, is the link to share with users so they can rent an address on your domain. When someone creates an address, after pay, they will be shown on the _Addresses_ section\
+ 
+
+5. Addresses get automatically purged if expired or unpaid, after 24 hours. After expiration date, users will be granted a 24 hours period to renew their address!
+
+6. On the user/buyer side, the webpage will present the _Create_ or _Renew_ address tabs. On the Create tab:\
+ 
+ - optional email
+ - the alias or username they want on your domain
+ - the LNbits URL, if not the same instance (for example the user has an LNbits wallet on https://s.lnbits.com and is renting an address from https://lnbits.com)
+ - the _Admin key_ for the wallet
+ - how many days to rent a username for - bellow shows the per day cost and total cost the user will have to pay
+7. On the Renew tab:\
+ 
+ - enter the Alias/username
+ - enter the wallet key
+ - press the _GET INFO_ button to retrieve your address data
+ - an expiration date will appear and the option to extend the duration of your address
diff --git a/lnbits/extensions/lnaddress/__init__.py b/lnbits/extensions/lnaddress/__init__.py
new file mode 100644
index 000000000..af2b93ab0
--- /dev/null
+++ b/lnbits/extensions/lnaddress/__init__.py
@@ -0,0 +1,28 @@
+import asyncio
+
+from fastapi import APIRouter
+
+from lnbits.db import Database
+from lnbits.helpers import template_renderer
+from lnbits.tasks import catch_everything_and_restart
+
+db = Database("ext_lnaddress")
+
+lnaddress_ext: APIRouter = APIRouter(
+ prefix="/lnaddress",
+ tags=["lnaddress"]
+)
+
+def lnaddress_renderer():
+ return template_renderer(["lnbits/extensions/lnaddress/templates"])
+
+
+from .lnurl import * # noqa
+from .tasks import wait_for_paid_invoices
+from .views import * # noqa
+from .views_api import * # noqa
+
+
+def lnurlp_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/lnaddress/cloudflare.py b/lnbits/extensions/lnaddress/cloudflare.py
new file mode 100644
index 000000000..3f3ed1711
--- /dev/null
+++ b/lnbits/extensions/lnaddress/cloudflare.py
@@ -0,0 +1,58 @@
+from lnbits.extensions.lnaddress.models import Domains
+import httpx, json
+
+
+async def cloudflare_create_record(
+ domain: Domains, ip: str
+):
+ url = (
+ "https://api.cloudflare.com/client/v4/zones/"
+ + domain.cf_zone_id
+ + "/dns_records"
+ )
+ header = {
+ "Authorization": "Bearer " + domain.cf_token,
+ "Content-Type": "application/json",
+ }
+
+ cf_response = ""
+ async with httpx.AsyncClient() as client:
+ try:
+ r = await client.post(
+ url,
+ headers=header,
+ json={
+ "type": "CNAME",
+ "name": domain.domain,
+ "content": ip,
+ "ttl": 0,
+ "proxied": False,
+ },
+ timeout=40,
+ )
+ cf_response = json.loads(r.text)
+ except AssertionError:
+ cf_response = "Error occured"
+ return cf_response
+
+
+async def cloudflare_deleterecord(domain: Domains, domain_id: str):
+ url = (
+ "https://api.cloudflare.com/client/v4/zones/"
+ + domain.cf_zone_id
+ + "/dns_records"
+ )
+ header = {
+ "Authorization": "Bearer " + domain.cf_token,
+ "Content-Type": "application/json",
+ }
+ async with httpx.AsyncClient() as client:
+ try:
+ r = await client.delete(
+ url + "/" + domain_id,
+ headers=header,
+ timeout=40,
+ )
+ cf_response = r.text
+ except AssertionError:
+ cf_response = "Error occured"
diff --git a/lnbits/extensions/lnaddress/config.json b/lnbits/extensions/lnaddress/config.json
new file mode 100644
index 000000000..f9946f358
--- /dev/null
+++ b/lnbits/extensions/lnaddress/config.json
@@ -0,0 +1,6 @@
+{
+ "name": "Lightning Address",
+ "short_description": "Sell LN addresses for your domain",
+ "icon": "alternate_email",
+ "contributors": ["talvasconcelos"]
+}
diff --git a/lnbits/extensions/lnaddress/crud.py b/lnbits/extensions/lnaddress/crud.py
new file mode 100644
index 000000000..36c0d54e4
--- /dev/null
+++ b/lnbits/extensions/lnaddress/crud.py
@@ -0,0 +1,193 @@
+from datetime import datetime, timedelta
+from typing import List, Optional, Union
+
+from lnbits.helpers import urlsafe_short_hash
+
+from . import db
+from .models import Addresses, CreateAddress, CreateDomain, Domains
+
+
+async def create_domain(
+ data: CreateDomain
+) -> Domains:
+ domain_id = urlsafe_short_hash()
+ await db.execute(
+ """
+ INSERT INTO lnaddress.domain (id, wallet, domain, webhook, cf_token, cf_zone_id, cost)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ domain_id,
+ data.wallet,
+ data.domain,
+ data.webhook,
+ data.cf_token,
+ data.cf_zone_id,
+ data.cost,
+ ),
+ )
+
+ new_domain = await get_domain(domain_id)
+ assert new_domain, "Newly created domain couldn't be retrieved"
+ return new_domain
+
+
+async def update_domain(domain_id: str, **kwargs) -> Domains:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE lnaddress.domain SET {q} WHERE id = ?", (*kwargs.values(), domain_id)
+ )
+ row = await db.fetchone(
+ "SELECT * FROM lnaddress.domain WHERE id = ?", (domain_id,)
+ )
+ assert row, "Newly updated domain couldn't be retrieved"
+ return Domains(**row)
+
+async def delete_domain(domain_id: str) -> None:
+ await db.execute("DELETE FROM lnaddress.domain WHERE id = ?", (domain_id,))
+
+async def get_domain(domain_id: str) -> Optional[Domains]:
+ row = await db.fetchone(
+ "SELECT * FROM lnaddress.domain WHERE id = ?", (domain_id,)
+ )
+ return Domains(**row) if row else None
+
+async def get_domains(wallet_ids: Union[str, List[str]]) -> List[Domains]:
+ if isinstance(wallet_ids, str):
+ wallet_ids = [wallet_ids]
+
+ q = ",".join(["?"] * len(wallet_ids))
+ rows = await db.fetchall(
+ f"SELECT * FROM lnaddress.domain WHERE wallet IN ({q})", (*wallet_ids,)
+ )
+
+ return [Domains(**row) for row in rows]
+
+## ADRESSES
+
+async def create_address(
+ payment_hash: str,
+ wallet: str,
+ data: CreateAddress
+) -> Addresses:
+ await db.execute(
+ """
+ INSERT INTO lnaddress.address (id, wallet, domain, email, username, wallet_key, wallet_endpoint, sats, duration, paid)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ payment_hash,
+ wallet,
+ data.domain,
+ data.email,
+ data.username,
+ data.wallet_key,
+ data.wallet_endpoint,
+ data.sats,
+ data.duration,
+ False,
+ ),
+ )
+
+ new_address = await get_address(payment_hash)
+ assert new_address, "Newly created address couldn't be retrieved"
+ return new_address
+
+async def get_address(address_id: str) -> Optional[Addresses]:
+ row = await db.fetchone(
+ "SELECT a.* FROM lnaddress.address AS a INNER JOIN lnaddress.domain AS d ON a.id = ? AND a.domain = d.id",
+ (address_id,),
+ )
+ return Addresses(**row) if row else None
+
+async def get_address_by_username(username: str, domain: str) -> Optional[Addresses]:
+ row = await db.fetchone(
+ "SELECT a.* FROM lnaddress.address AS a INNER JOIN lnaddress.domain AS d ON a.username = ? AND d.domain = ?",
+ # "SELECT * FROM lnaddress.address WHERE username = ? AND domain = ?",
+ (username, domain,),
+ )
+ print("ADD", row)
+ return Addresses(**row) if row else None
+
+async def delete_address(address_id: str) -> None:
+ await db.execute("DELETE FROM lnaddress.address WHERE id = ?", (address_id,))
+
+async def get_addresses(wallet_ids: Union[str, List[str]]) -> List[Addresses]:
+ if isinstance(wallet_ids, str):
+ wallet_ids = [wallet_ids]
+
+ q = ",".join(["?"] * len(wallet_ids))
+ rows = await db.fetchall(
+ f"SELECT * FROM lnaddress.address WHERE wallet IN ({q})",
+ (*wallet_ids,),
+ )
+ print([Addresses(**row) for row in rows])
+ return [Addresses(**row) for row in rows]
+
+async def set_address_paid(payment_hash: str) -> Addresses:
+ _address = await get_address(payment_hash)
+ address = _address._asdict()
+
+ if address["paid"] == False:
+ await db.execute(
+ """
+ UPDATE lnaddress.address
+ SET paid = true
+ WHERE id = ?
+ """,
+ (payment_hash,),
+ )
+
+ new_address = await get_address(payment_hash)
+ assert new_address, "Newly paid address couldn't be retrieved"
+ return new_address
+
+async def set_address_renewed(address_id: str, duration: int):
+ _address = await get_address(address_id)
+ address = _address._asdict()
+
+ extend_duration = int(address["duration"]) + duration
+ await db.execute(
+ """
+ UPDATE lnaddress.address
+ SET duration = ?
+ WHERE id = ?
+ """,
+ (extend_duration, address_id,),
+ )
+ updated_address = await get_address(address_id)
+ assert updated_address, "Renewed address couldn't be retrieved"
+ return updated_address
+
+
+async def check_address_available(username: str, domain: str):
+ row, = await db.fetchone(
+ "SELECT COUNT(username) FROM lnaddress.address WHERE username = ? AND domain = ?",
+ (username, domain,),
+ )
+ return row
+
+async def purge_addresses(domain_id: str):
+
+ rows = await db.fetchall(
+ "SELECT * FROM lnaddress.address WHERE domain = ?",
+ (domain_id, ),
+ )
+
+ now = datetime.now().timestamp()
+
+ for row in rows:
+ r = Addresses(**row)._asdict()
+
+ start = datetime.fromtimestamp(r["time"])
+ paid = r["paid"]
+ pay_expire = now > start.timestamp() + 86400 #if payment wasn't made in 1 day
+ expired = now > (start + timedelta(days = r["duration"] + 1)).timestamp() #give user 1 day to topup is address
+
+ if not paid and pay_expire:
+ print("DELETE UNP_PAY_EXP", r["username"])
+ await delete_address(r["id"])
+
+ if paid and expired:
+ print("DELETE PAID_EXP", r["username"])
+ await delete_address(r["id"])
diff --git a/lnbits/extensions/lnaddress/lnurl.py b/lnbits/extensions/lnaddress/lnurl.py
new file mode 100644
index 000000000..ab043a118
--- /dev/null
+++ b/lnbits/extensions/lnaddress/lnurl.py
@@ -0,0 +1,92 @@
+import hashlib
+from datetime import datetime, timedelta
+
+import httpx
+from fastapi.params import Query
+from lnurl import ( # type: ignore
+ LnurlErrorResponse,
+ LnurlPayActionResponse,
+ LnurlPayResponse,
+)
+from starlette.requests import Request
+
+from . import lnaddress_ext
+from .crud import get_address, get_address_by_username, get_domain
+
+
+async def lnurl_response(username: str, domain: str, request: Request):
+ address = await get_address_by_username(username, domain)
+
+ if not address:
+ return {"status": "ERROR", "reason": "Address not found."}
+
+ ## CHECK IF USER IS STILL VALID/PAYING
+ now = datetime.now().timestamp()
+ start = datetime.fromtimestamp(address.time)
+ expiration = (start + timedelta(days = address.duration)).timestamp()
+
+ if now > expiration:
+ return LnurlErrorResponse(reason="Address has expired.").dict()
+
+ resp = LnurlPayResponse(
+ callback=request.url_for("lnaddress.lnurl_callback", address_id=address.id, _external=True),
+ min_sendable=1000,
+ max_sendable=1000000000,
+ metadata=await address.lnurlpay_metadata(),
+ )
+
+ return resp.dict()
+
+@lnaddress_ext.get("/lnurl/cb/{address_id}", name="lnaddress.lnurl_callback")
+async def lnurl_callback(address_id, amount: int = Query(...)):
+ address = await get_address(address_id)
+
+ if not address:
+ return LnurlErrorResponse(
+ reason=f"Address not found"
+ ).dict()
+
+ amount_received = amount
+ # min = 1000
+ # max = 1000000000
+
+ # if amount_received < min:
+ # return LnurlErrorResponse(
+ # reason=f"Amount {amount_received} is smaller than minimum."
+ # ).dict()
+
+ # elif amount_received > max:
+ # return jsonify(
+ # LnurlErrorResponse(
+ # reason=f"Amount {amount_received} is greater than maximum."
+ # ).dict()
+ # )
+
+ domain = await get_domain(address.domain)
+
+ base_url = address.wallet_endpoint[:-1] if address.wallet_endpoint.endswith('/') else address.wallet_endpoint
+
+ async with httpx.AsyncClient() as client:
+ try:
+ call = await client.post(
+ base_url + "/api/v1/payments",
+ headers={"X-Api-Key": address.wallet_key, "Content-Type": "application/json"},
+ json={
+ "out": False,
+ "amount": int(amount_received / 1000),
+ "description_hash": hashlib.sha256((await address.lnurlpay_metadata()).encode("utf-8")).hexdigest(),
+ "extra": {"tag": f"Payment to {address.username}@{domain.domain}"},
+ },
+ timeout=40,
+ )
+
+ r = call.json()
+ except AssertionError as e:
+ return LnurlErrorResponse(reason="ERROR")
+
+ resp = LnurlPayActionResponse(
+ pr=r["payment_request"],
+ routes=[],
+ )
+
+ return resp.dict()
diff --git a/lnbits/extensions/lnaddress/migrations.py b/lnbits/extensions/lnaddress/migrations.py
new file mode 100644
index 000000000..4c8b8be19
--- /dev/null
+++ b/lnbits/extensions/lnaddress/migrations.py
@@ -0,0 +1,41 @@
+async def m001_initial(db):
+ await db.execute(
+ """
+ CREATE TABLE lnaddress.domain (
+ id TEXT PRIMARY KEY,
+ wallet TEXT NOT NULL,
+ domain TEXT NOT NULL,
+ webhook TEXT,
+ cf_token TEXT NOT NULL,
+ cf_zone_id TEXT NOT NULL,
+ cost INTEGER NOT NULL,
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
+ );
+ """
+ )
+
+async def m002_addresses(db):
+ await db.execute(
+ """
+ CREATE TABLE lnaddress.address (
+ id TEXT PRIMARY KEY,
+ wallet TEXT NOT NULL,
+ domain TEXT NOT NULL,
+ email TEXT,
+ username TEXT NOT NULL,
+ wallet_key TEXT NOT NULL,
+ wallet_endpoint TEXT NOT NULL,
+ sats INTEGER NOT NULL,
+ duration INTEGER NOT NULL,
+ paid BOOLEAN NOT NULL,
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
+ );
+ """
+ )
+
+# async def m003_create_unique_indexes(db):
+# await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS address_at_domain ON lnaddress.address (domain, username);")
diff --git a/lnbits/extensions/lnaddress/models.py b/lnbits/extensions/lnaddress/models.py
new file mode 100644
index 000000000..e0ff9fa1d
--- /dev/null
+++ b/lnbits/extensions/lnaddress/models.py
@@ -0,0 +1,53 @@
+import json
+from typing import Optional
+
+from fastapi.params import Query
+from lnurl.types import LnurlPayMetadata
+from pydantic.main import BaseModel # type: ignore
+
+
+class CreateDomain(BaseModel):
+ wallet: str = Query(...)
+ domain: str = Query(...)
+ cf_token: str = Query(...)
+ cf_zone_id: str = Query(...)
+ webhook: str = Query(None)
+ cost: int = Query(..., ge=0)
+
+class Domains(BaseModel):
+ id: str
+ wallet: str
+ domain: str
+ cf_token: str
+ cf_zone_id: str
+ webhook: str
+ cost: int
+ time: int
+
+class CreateAddress(BaseModel):
+ domain: str = Query(...)
+ username: str = Query(...)
+ email: str = Query(None)
+ wallet_endpoint: str = Query(...)
+ wallet_key: str = Query(...)
+ sats: int = Query(..., ge=0)
+ duration: int = Query(..., ge=1)
+
+class Addresses(BaseModel):
+ id: str
+ wallet: str
+ domain: str
+ email: str
+ username: str
+ wallet_key: str
+ wallet_endpoint: str
+ sats: int
+ duration: int
+ paid: bool
+ time: int
+
+ async def lnurlpay_metadata(self) -> LnurlPayMetadata:
+ text = f"Payment to {self.username}"
+ metadata = [["text/plain", text]]
+
+ return LnurlPayMetadata(json.dumps(metadata))
diff --git a/lnbits/extensions/lnaddress/tasks.py b/lnbits/extensions/lnaddress/tasks.py
new file mode 100644
index 000000000..710b84bda
--- /dev/null
+++ b/lnbits/extensions/lnaddress/tasks.py
@@ -0,0 +1,59 @@
+import asyncio
+
+import httpx
+
+from lnbits.core.models import Payment
+from lnbits.tasks import register_invoice_listener
+
+from .crud import get_address, get_domain, set_address_paid, set_address_renewed
+
+
+async def wait_for_paid_invoices():
+ invoice_queue = asyncio.Queue()
+ register_invoice_listener(invoice_queue)
+
+ while True:
+ payment = await invoice_queue.get()
+ await on_invoice_paid(payment)
+
+
+async def call_webhook_on_paid(payment_hash):
+ ### Use webhook to notify about cloudflare registration
+ address = await get_address(payment_hash)
+ domain = await get_domain(address.domain)
+
+ if not domain.webhook:
+ return
+
+ async with httpx.AsyncClient() as client:
+ try:
+ r = await client.post(
+ domain.webhook,
+ json={
+ "domain": domain.domain,
+ "address": address.username,
+ "email": address.email,
+ "cost": str(address.sats) + " sats",
+ "duration": str(address.duration) + " days",
+ },
+ timeout=40,
+ )
+ except AssertionError:
+ webhook = None
+
+
+async def on_invoice_paid(payment: Payment) -> None:
+ if "lnaddress" == payment.extra.get("tag"):
+
+ await payment.set_pending(False)
+ await set_address_paid(payment_hash=payment.payment_hash)
+ await call_webhook_on_paid(payment.payment_hash)
+
+ elif "renew lnaddress" == payment.extra.get("tag"):
+
+ await payment.set_pending(False)
+ await set_address_renewed(address_id=payment.extra["id"], duration=payment.extra["duration"])
+ await call_webhook_on_paid(payment.payment_hash)
+
+ else:
+ return
diff --git a/lnbits/extensions/lnaddress/templates/lnaddress/_api_docs.html b/lnbits/extensions/lnaddress/templates/lnaddress/_api_docs.html
new file mode 100644
index 000000000..ab7ab4bd3
--- /dev/null
+++ b/lnbits/extensions/lnaddress/templates/lnaddress/_api_docs.html
@@ -0,0 +1,174 @@
+
+
+
+
+ lnAddress: Get paid sats to sell lightning addresses on your domains
+
+
+ Charge people for using your domain name...
+
+ More details
+
+
+ Created by,
+ talvasconcelos
+
+
+
+
+
+
+
+
+ GET
+ lnaddress/api/v1/domains
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ JSON list of users
+ Curl example
+ curl -X GET {{ request.url_root }}lnaddress/api/v1/domains -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ POST
+ /lnAddress/api/v1/domains
+ Headers
+ {"X-Api-Key": <string>, "Content-type":
+ "application/json"}
+
+ Body (application/json) - "wallet" is a YOUR wallet ID
+
+ {"wallet": <string>, "domain": <string>, "cf_token":
+ <string>,"cf_zone_id": <string>,"webhook": <Optional
+ string> ,"cost": <integer>}
+
+ Returns 201 CREATED (application/json)
+
+ {"id": <string>, "wallet": <string>, "domain":
+ <string>, "webhook": <string>, "cf_token": <string>,
+ "cf_zone_id": <string>, "cost": <integer>}
+ Curl example
+ curl -X POST {{ request.url_root }}lnaddress/api/v1/domains -d
+ '{"wallet": "{{ user.wallets[0].id }}", "domain": <string>,
+ "cf_token": <string>,"cf_zone_id": <string>,"webhook":
+ <Optional string> ,"cost": <integer>}' -H "X-Api-Key: {{
+ user.wallets[0].inkey }}" -H "Content-type: application/json"
+
+
+
+
+
+
+
+ DELETE
+ /lnaddress/api/v1/domains/<domain_id>
+ Headers
+ {"X-Api-Key": <string>}
+ Curl example
+ curl -X DELETE {{ request.url_root
+ }}lnaddress/api/v1/domains/<domain_id> -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ GET
+ lnaddress/api/v1/addresses
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ JSON list of addresses
+ Curl example
+ curl -X GET {{ request.url_root }}lnaddress/api/v1/addresses -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ GET
+ lnaddress/api/v1/address/<domain>/<username>/<wallet_key>
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ JSON list of addresses
+ Curl example
+ curl -X GET {{ request.url_root
+ }}lnaddress/api/v1/address/<domain>/<username>/<wallet_key>
+ -H "X-Api-Key: {{ user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ POST
+ /lnaddress/api/v1/address/<domain_id>
+ Headers
+ {"X-Api-Key": <string>}
+ Curl example
+ curl -X POST {{ request.url_root
+ }}lnaddress/api/v1/address/<domain_id> -d '{"domain":
+ <string>, "username": <string>,"email": <Optional
+ string>, "wallet_endpoint": <string>, "wallet_key":
+ <string>, "sats": <integer> "duration": <integer>,}'
+ -H "X-Api-Key: {{ user.wallets[0].inkey }}" -H "Content-type:
+ application/json"
+
+
+
+
+
diff --git a/lnbits/extensions/lnaddress/templates/lnaddress/display.html b/lnbits/extensions/lnaddress/templates/lnaddress/display.html
new file mode 100644
index 000000000..67745fa85
--- /dev/null
+++ b/lnbits/extensions/lnaddress/templates/lnaddress/display.html
@@ -0,0 +1,436 @@
+{% extends "public.html" %} {% block page %}
+
+
+
+
+
+
+ tab = val.name"
+ >
+ tab = val.name"
+ >
+
+
+
+
+
+
+
+ {{ domain_domain }}
+
+
+ Your Lightning Address: {% raw
+ %}{{this.formDialog.data.username}}{% endraw %}@{{domain_domain}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cost per day: {{ domain_cost }} sats
+ {% raw %} Total cost: {{amountSats}} sats {% endraw %}
+
+
+ Submit
+ Cancel
+
+
+
+
+
+
+ {{ domain_domain }}
+
+
+ Renew your Lightning Address: {% raw
+ %}{{this.formDialog.data.username}}{% endraw %}@{{domain_domain}}
+
+
+
+
+
+
+
+
+
+ {% raw %}
+
+ LN Address:
+ {{renewDialog.data.username}}@{{renewDialog.data.domain}}
+
+ Expires at: {{renewDialog.data.expiration}}
+
+ {% endraw %}
+
+
Get Info
+
+
+
+
+ Cost per day: {{ domain_cost }} sats
+ {% raw %} Total cost: {{amountSats}} sats {% endraw %}
+
+
+ Submit
+ Cancel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Copy invoice
+ Close
+
+
+
+
+
+{% endblock %} {% block scripts %}
+
+{% endblock %}
diff --git a/lnbits/extensions/lnaddress/templates/lnaddress/index.html b/lnbits/extensions/lnaddress/templates/lnaddress/index.html
new file mode 100644
index 000000000..ef04b5937
--- /dev/null
+++ b/lnbits/extensions/lnaddress/templates/lnaddress/index.html
@@ -0,0 +1,507 @@
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
+%} {% block page %}
+
+
+
+
+ Add Domain
+
+
+
+
+
+
+
Domains
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+
+ {{ col.value }}
+
+
+
+
+
+
+
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
Addresses
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+
+ {{ col.value }}
+
+
+
+
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
+ {{SITE_TITLE}} LN Address extension
+
+
+
+
+ {% include "lnaddress/_api_docs.html" %}
+
+
+
+
+
+
+
+
+ The domain to use ex: "example.com"
+
+ Your API key in cloudflare
+
+
+ Create a "Edit zone DNS" API token in cloudflare
+
+
+ How much to charge per day
+
+ Update Form
+ Create Domain
+ Cancel
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }}
+
+{% endblock %}
diff --git a/lnbits/extensions/lnaddress/views.py b/lnbits/extensions/lnaddress/views.py
new file mode 100644
index 000000000..9f6542df1
--- /dev/null
+++ b/lnbits/extensions/lnaddress/views.py
@@ -0,0 +1,43 @@
+from http import HTTPStatus
+
+from fastapi import Request
+from fastapi.params import Depends
+from fastapi.templating import Jinja2Templates
+from starlette.exceptions import HTTPException
+from starlette.responses import HTMLResponse
+
+from lnbits.core.crud import get_wallet
+from lnbits.core.models import User
+from lnbits.decorators import check_user_exists
+
+from . import lnaddress_ext, lnaddress_renderer
+from .crud import get_domain, purge_addresses
+
+templates = Jinja2Templates(directory="templates")
+
+
+@lnaddress_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return lnaddress_renderer().TemplateResponse("lnaddress/index.html", {"request": request, "user": user.dict()})
+
+@lnaddress_ext.get("/{domain_id}")
+async def display(domain_id, request: Request):
+ domain = await get_domain(domain_id)
+ if not domain:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
+ )
+
+ await purge_addresses(domain_id, response_class=HTMLResponse)
+
+ wallet = await get_wallet(domain.wallet)
+
+ return lnaddress_renderer().TemplateResponse(
+ "lnaddress/display.html",{
+ "request": request,
+ "domain_id":domain.id,
+ "domain_domain": domain.domain,
+ "domain_cost": domain.cost,
+ "domain_wallet_inkey": wallet.inkey
+ }
+ )
diff --git a/lnbits/extensions/lnaddress/views_api.py b/lnbits/extensions/lnaddress/views_api.py
new file mode 100644
index 000000000..38cd723ef
--- /dev/null
+++ b/lnbits/extensions/lnaddress/views_api.py
@@ -0,0 +1,262 @@
+from http import HTTPStatus
+from urllib.parse import urlparse
+
+from fastapi import Request
+from fastapi.params import Depends, Query
+from starlette.exceptions import HTTPException
+
+from lnbits.core.crud import get_user
+from lnbits.core.services import check_invoice_status, create_invoice
+from lnbits.decorators import WalletTypeInfo, get_key_type
+from lnbits.extensions.lnaddress.models import CreateAddress, CreateDomain
+
+from . import lnaddress_ext
+from .cloudflare import cloudflare_create_record, cloudflare_deleterecord
+from .crud import (
+ check_address_available,
+ create_address,
+ create_domain,
+ delete_address,
+ delete_domain,
+ get_address,
+ get_address_by_username,
+ get_addresses,
+ get_domain,
+ get_domains,
+ update_domain,
+)
+
+
+# DOMAINS
+@lnaddress_ext.get("/api/v1/domains")
+async def api_domains(
+ g: WalletTypeInfo = Depends(get_key_type),
+ all_wallets: bool = Query(False),
+):
+ wallet_ids = [g.wallet.id]
+
+ if all_wallets:
+ wallet_ids = (await get_user(g.wallet.user)).wallet_ids
+
+ return [domain.dict() for domain in await get_domains(wallet_ids)]
+
+
+@lnaddress_ext.post("/api/v1/domains")
+@lnaddress_ext.put("/api/v1/domains/{domain_id}")
+async def api_domain_create(request: Request,data: CreateDomain, domain_id=None, g: WalletTypeInfo = Depends(get_key_type)):
+ if domain_id:
+ domain = await get_domain(domain_id)
+
+ if not domain:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="Domain does not exist.",
+ )
+
+ if domain.wallet != g.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Not your domain",
+ )
+
+ domain = await update_domain(domain_id, **data.dict())
+ else:
+
+ domain = await create_domain(data=data)
+ root_url = urlparse(request.url.path).netloc
+ #root_url = request.url_root
+
+ cf_response = await cloudflare_create_record(
+ domain=domain,
+ ip=root_url,
+ )
+
+ if not cf_response or cf_response["success"] != True:
+ await delete_domain(domain.id)
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail="Problem with cloudflare: " + cf_response["errors"][0]["message"],
+ )
+
+ return domain.dict()
+
+@lnaddress_ext.delete("/api/v1/domains/{domain_id}")
+async def api_domain_delete(domain_id, g: WalletTypeInfo = Depends(get_key_type)):
+ domain = await get_domain(domain_id)
+
+ if not domain:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="Domain does not exist.",
+ )
+
+ if domain.wallet != g.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Not your domain",
+ )
+
+ await delete_domain(domain_id)
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
+
+# ADDRESSES
+
+@lnaddress_ext.get("/api/v1/addresses")
+async def api_addresses(
+ g: WalletTypeInfo = Depends(get_key_type),
+ all_wallets: bool = Query(False),
+):
+ wallet_ids = [g.wallet.id]
+
+ if all_wallets:
+ wallet_ids = (await get_user(g.wallet.user)).wallet_ids
+
+ return [address.dict() for address in await get_addresses(wallet_ids)]
+
+@lnaddress_ext.get("/api/v1/address/{domain}/{username}/{wallet_key}")
+async def api_get_user_info(username, wallet_key, domain):
+ address = await get_address_by_username(username, domain)
+
+ if not address:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="Address does not exist.",
+ )
+
+ if address.wallet_key != wallet_key:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Incorrect user/wallet information.",
+ )
+
+ return address.dict()
+
+@lnaddress_ext.get("/api/v1/address/availabity/{domain_id}/{username}")
+async def api_check_available_username(domain_id, username):
+ used_username = await check_address_available(username, domain_id)
+
+ return used_username
+
+@lnaddress_ext.post("/api/v1/address/{domain_id}")
+@lnaddress_ext.put("/api/v1/address/{domain_id}/{user}/{wallet_key}")
+async def api_lnaddress_make_address(domain_id, data: CreateAddress, user=None, wallet_key=None):
+ domain = await get_domain(domain_id)
+
+ # If the request is coming for the non-existant domain
+ if not domain:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="The domain does not exist.",
+ )
+
+ domain_cost = domain[6]
+ sats = data.sats
+
+ ## FAILSAFE FOR CREATING ADDRESSES BY API
+ if(domain_cost * data.duration != data.sats):
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="The amount is not correct. Either 'duration', or 'sats' are wrong.",
+ )
+
+ if user:
+ print("USER", user, domain.domain)
+ address = await get_address_by_username(user, domain.domain)
+
+ if not address:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="The address does not exist.",
+ )
+
+ if address.wallet_key != wallet_key:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Not your address.",
+ )
+
+ try:
+ payment_hash, payment_request = await create_invoice(
+ wallet_id=domain.wallet,
+ amount=data.sats,
+ memo=f"Renew {data.username}@{domain.domain} for {sats} sats for {data.duration} more days",
+ extra={
+ "tag": "renew lnaddress",
+ "id": address.id,
+ "duration": data.duration
+ },
+ )
+
+ except Exception as e:
+ raise HTTPException(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+ detail=str(e),
+ )
+ else:
+ used_username = await check_address_available(data.username, data.domain)
+ # If username is already taken
+ if used_username:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail="Alias/username already taken.",
+ )
+
+ ## ALL OK - create an invoice and return it to the user
+
+ try:
+ payment_hash, payment_request = await create_invoice(
+ wallet_id=domain.wallet,
+ amount=sats,
+ memo=f"LNAddress {data.username}@{domain.domain} for {sats} sats for {data.duration} days",
+ extra={"tag": "lnaddress"},
+ )
+ except Exception as e:
+ raise HTTPException(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+ detail=str(e),
+ )
+
+ address = await create_address(
+ payment_hash=payment_hash, wallet=domain.wallet, data=data
+ )
+
+ if not address:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="LNAddress could not be fetched.",
+ )
+
+ return {"payment_hash": payment_hash, "payment_request": payment_request}
+
+@lnaddress_ext.get("/api/v1/addresses/{payment_hash}")
+async def api_address_send_address(payment_hash):
+ address = await get_address(payment_hash)
+ domain = await get_domain(address.domain)
+ try:
+ status = await check_invoice_status(domain.wallet, payment_hash)
+ is_paid = not status.pending
+ except Exception as e:
+ return {"paid": False, 'error': str(e)}
+
+ if is_paid:
+ return {"paid": True}
+
+ return {"paid": False}
+
+@lnaddress_ext.delete("/api/v1/addresses/{address_id}")
+async def api_address_delete(address_id, g: WalletTypeInfo = Depends(get_key_type)):
+ address = await get_address(address_id)
+ if not address:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="Address does not exist.",
+ )
+ if address.wallet != g.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Not your address.",
+ )
+
+ await delete_address(address_id)
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
+
\ No newline at end of file