diff --git a/lnbits/extensions/nostrnip5/README.md b/lnbits/extensions/nostrnip5/README.md
new file mode 100644
index 00000000..b8912fa2
--- /dev/null
+++ b/lnbits/extensions/nostrnip5/README.md
@@ -0,0 +1,44 @@
+# Nostr NIP-05
+
+## Allow users to NIP-05 verify themselves at a domain you control
+
+This extension allows users to sell NIP-05 verification to other nostr users on a domain they control.
+
+## Usage
+
+1. Create a Domain by clicking "NEW DOMAIN"\
+2. Fill the options for your DOMAIN
+ - select the wallet
+ - select the fiat currency the invoice will be denominated in
+ - select an amount in fiat to charge users for verification
+ - enter the domain (or subdomain) you want to provide verification for
+ - Note, you must own this domain and have access to a web server
+3. You can then use share your signup link with your users to allow them to sign up
+
+
+## Installation
+
+In order for this to work, you need to have ownership of a domain name, and access to a web server that this domain is pointed to.
+
+Then, you'll need to set up a proxy that points `https://{your_domain}/.well-known/nostr.json` to `https://{your_lnbits}/nostrnip5/api/v1/domain/{domain_id}/nostr.json`
+
+Example nginx configuration
+
+```
+## Proxy Server Caching
+proxy_cache_path /tmp/nginx_cache keys_zone=nip5_cache:5m levels=1:2 inactive=300s max_size=100m use_temp_path=off;
+
+location /.well-known/nostr.json {
+ proxy_pass https://{your_lnbits}/nostrnip5/api/v1/domain/{domain_id}/nostr.json;
+ proxy_set_header Host {your_lnbits};
+ proxy_ssl_server_name on;
+
+ expires 5m;
+ add_header Cache-Control "public, no-transform";
+
+ proxy_cache nip5_cache;
+ proxy_cache_lock on;
+ proxy_cache_valid 200 300s;
+ proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504;
+}
+```
\ No newline at end of file
diff --git a/lnbits/extensions/nostrnip5/__init__.py b/lnbits/extensions/nostrnip5/__init__.py
new file mode 100644
index 00000000..a9a2ea1c
--- /dev/null
+++ b/lnbits/extensions/nostrnip5/__init__.py
@@ -0,0 +1,36 @@
+import asyncio
+
+from fastapi import APIRouter
+from starlette.staticfiles import StaticFiles
+
+from lnbits.db import Database
+from lnbits.helpers import template_renderer
+from lnbits.tasks import catch_everything_and_restart
+
+db = Database("ext_nostrnip5")
+
+nostrnip5_static_files = [
+ {
+ "path": "/nostrnip5/static",
+ "app": StaticFiles(directory="lnbits/extensions/nostrnip5/static"),
+ "name": "nostrnip5_static",
+ }
+]
+
+nostrnip5_ext: APIRouter = APIRouter(prefix="/nostrnip5", tags=["nostrnip5"])
+
+
+def nostrnip5_renderer():
+ return template_renderer(["lnbits/extensions/nostrnip5/templates"])
+
+
+from .tasks import wait_for_paid_invoices
+
+
+def nostrnip5_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
+
+
+from .views import * # noqa
+from .views_api import * # noqa
diff --git a/lnbits/extensions/nostrnip5/config.json b/lnbits/extensions/nostrnip5/config.json
new file mode 100644
index 00000000..658723aa
--- /dev/null
+++ b/lnbits/extensions/nostrnip5/config.json
@@ -0,0 +1,6 @@
+{
+ "name": "Nostr NIP-5",
+ "short_description": "Verify addresses for Nostr NIP-5",
+ "icon": "request_quote",
+ "contributors": ["leesalminen"]
+}
diff --git a/lnbits/extensions/nostrnip5/crud.py b/lnbits/extensions/nostrnip5/crud.py
new file mode 100644
index 00000000..b445e991
--- /dev/null
+++ b/lnbits/extensions/nostrnip5/crud.py
@@ -0,0 +1,186 @@
+from typing import List, Optional, Union
+
+from lnbits.helpers import urlsafe_short_hash
+
+from . import db
+from .models import Address, CreateAddressData, CreateDomainData, Domain
+
+
+async def get_domain(domain_id: str) -> Optional[Domain]:
+ row = await db.fetchone(
+ "SELECT * FROM nostrnip5.domains WHERE id = ?", (domain_id,)
+ )
+ return Domain.from_row(row) if row else None
+
+
+async def get_domain_by_name(domain: str) -> Optional[Domain]:
+ row = await db.fetchone(
+ "SELECT * FROM nostrnip5.domains WHERE domain = ?", (domain,)
+ )
+ return Domain.from_row(row) if row else None
+
+
+async def get_domains(wallet_ids: Union[str, List[str]]) -> List[Domain]:
+ if isinstance(wallet_ids, str):
+ wallet_ids = [wallet_ids]
+
+ q = ",".join(["?"] * len(wallet_ids))
+ rows = await db.fetchall(
+ f"SELECT * FROM nostrnip5.domains WHERE wallet IN ({q})", (*wallet_ids,)
+ )
+
+ return [Domain.from_row(row) for row in rows]
+
+
+async def get_address(domain_id: str, address_id: str) -> Optional[Address]:
+ row = await db.fetchone(
+ "SELECT * FROM nostrnip5.addresses WHERE domain_id = ? AND id = ?",
+ (
+ domain_id,
+ address_id,
+ ),
+ )
+ return Address.from_row(row) if row else None
+
+
+async def get_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 = ?",
+ (
+ domain_id,
+ local_part.lower(),
+ ),
+ )
+ return Address.from_row(row) if row else None
+
+
+async def get_addresses(domain_id: str) -> List[Address]:
+ rows = await db.fetchall(
+ f"SELECT * FROM nostrnip5.addresses WHERE domain_id = ?", (domain_id,)
+ )
+
+ return [Address.from_row(row) for row in rows]
+
+
+async def get_all_addresses(wallet_ids: Union[str, List[str]]) -> List[Address]:
+ if isinstance(wallet_ids, str):
+ wallet_ids = [wallet_ids]
+
+ q = ",".join(["?"] * len(wallet_ids))
+ rows = await db.fetchall(
+ f"""
+ SELECT a.*
+ FROM nostrnip5.addresses a
+ JOIN nostrnip5.domains d ON d.id = a.domain_id
+ WHERE d.wallet IN ({q})
+ """,
+ (*wallet_ids,),
+ )
+
+ return [Address.from_row(row) for row in rows]
+
+
+async def activate_address(domain_id: str, address_id: str) -> Address:
+ await db.execute(
+ """
+ UPDATE nostrnip5.addresses
+ SET active = true
+ WHERE domain_id = ?
+ AND id = ?
+ """,
+ (
+ domain_id,
+ address_id,
+ ),
+ )
+
+ address = await get_address(domain_id, address_id)
+ assert address, "Newly updated address couldn't be retrieved"
+ return address
+
+
+async def rotate_address(domain_id: str, address_id: str, pubkey: str) -> Address:
+ await db.execute(
+ """
+ UPDATE nostrnip5.addresses
+ SET pubkey = ?
+ WHERE domain_id = ?
+ AND id = ?
+ """,
+ (
+ pubkey,
+ domain_id,
+ address_id,
+ ),
+ )
+
+ 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) -> bool:
+ await db.execute(
+ """
+ DELETE FROM nostrnip5.addresses WHERE domain_id = ?
+ """,
+ (domain_id,),
+ )
+
+ await db.execute(
+ """
+ DELETE FROM nostrnip5.domains WHERE id = ?
+ """,
+ (domain_id,),
+ )
+
+ return True
+
+
+async def delete_address(address_id) -> bool:
+ await db.execute(
+ """
+ DELETE FROM nostrnip5.addresses WHERE id = ?
+ """,
+ (address_id,),
+ )
+
+
+async def create_address_internal(domain_id: str, data: CreateAddressData) -> Address:
+ address_id = urlsafe_short_hash()
+
+ await db.execute(
+ """
+ INSERT INTO nostrnip5.addresses (id, domain_id, local_part, pubkey, active)
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ (
+ address_id,
+ domain_id,
+ data.local_part.lower(),
+ data.pubkey,
+ False,
+ ),
+ )
+
+ address = await get_address(domain_id, address_id)
+ assert address, "Newly created address couldn't be retrieved"
+ return address
+
+
+async def create_domain_internal(wallet_id: str, data: CreateDomainData) -> Domain:
+ domain_id = urlsafe_short_hash()
+
+ await db.execute(
+ """
+ INSERT INTO nostrnip5.domains (id, wallet, currency, amount, domain)
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ (domain_id, wallet_id, data.currency, int(data.amount * 100), data.domain),
+ )
+
+ domain = await get_domain(domain_id)
+ assert domain, "Newly created domain couldn't be retrieved"
+ return domain
diff --git a/lnbits/extensions/nostrnip5/migrations.py b/lnbits/extensions/nostrnip5/migrations.py
new file mode 100644
index 00000000..8e81a1a4
--- /dev/null
+++ b/lnbits/extensions/nostrnip5/migrations.py
@@ -0,0 +1,35 @@
+async def m001_initial_invoices(db):
+
+ await db.execute(
+ f"""
+ CREATE TABLE nostrnip5.domains (
+ id TEXT PRIMARY KEY,
+ wallet TEXT NOT NULL,
+
+ currency TEXT NOT NULL,
+ amount INTEGER NOT NULL,
+
+ domain TEXT NOT NULL,
+
+ time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
+ );
+ """
+ )
+
+ await db.execute(
+ f"""
+ CREATE TABLE nostrnip5.addresses (
+ id TEXT PRIMARY KEY,
+ domain_id TEXT NOT NULL,
+
+ local_part TEXT NOT NULL,
+ pubkey TEXT NOT NULL,
+
+ active BOOLEAN NOT NULL DEFAULT false,
+
+ time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
+
+ FOREIGN KEY(domain_id) REFERENCES {db.references_schema}domains(id)
+ );
+ """
+ )
diff --git a/lnbits/extensions/nostrnip5/models.py b/lnbits/extensions/nostrnip5/models.py
new file mode 100644
index 00000000..e02f2909
--- /dev/null
+++ b/lnbits/extensions/nostrnip5/models.py
@@ -0,0 +1,50 @@
+from enum import Enum
+from sqlite3 import Row
+from typing import List, Optional
+
+from fastapi.param_functions import Query
+from pydantic import BaseModel
+
+
+class RotateAddressData(BaseModel):
+ pubkey: str
+
+
+class CreateAddressData(BaseModel):
+ domain_id: str
+ local_part: str
+ pubkey: str
+ active: bool = False
+
+
+class CreateDomainData(BaseModel):
+ wallet: str
+ currency: str
+ amount: float = Query(..., ge=0.01)
+ domain: str
+
+
+class Domain(BaseModel):
+ id: str
+ wallet: str
+ currency: str
+ amount: int
+ domain: str
+ time: int
+
+ @classmethod
+ def from_row(cls, row: Row) -> "Domain":
+ return cls(**dict(row))
+
+
+class Address(BaseModel):
+ id: str
+ domain_id: str
+ local_part: str
+ pubkey: str
+ active: bool
+ time: int
+
+ @classmethod
+ def from_row(cls, row: Row) -> "Address":
+ return cls(**dict(row))
diff --git a/lnbits/extensions/nostrnip5/static/css/signup.css b/lnbits/extensions/nostrnip5/static/css/signup.css
new file mode 100644
index 00000000..e69de29b
diff --git a/lnbits/extensions/nostrnip5/tasks.py b/lnbits/extensions/nostrnip5/tasks.py
new file mode 100644
index 00000000..3cedc9f5
--- /dev/null
+++ b/lnbits/extensions/nostrnip5/tasks.py
@@ -0,0 +1,34 @@
+import asyncio
+import json
+
+from lnbits.core.models import Payment
+from lnbits.helpers import urlsafe_short_hash
+from lnbits.tasks import internal_invoice_queue, register_invoice_listener
+
+from .crud import activate_address
+
+
+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 on_invoice_paid(payment: Payment) -> None:
+ if payment.extra.get("tag") != "nostrnip5":
+ # not relevant
+ return
+
+ domain_id = payment.extra.get("domain_id")
+ address_id = payment.extra.get("address_id")
+
+ print("Activating NOSTR NIP-05")
+ print(domain_id)
+ print(address_id)
+
+ active = await activate_address(domain_id, address_id)
+
+ return
diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html
new file mode 100644
index 00000000..17197d5a
--- /dev/null
+++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html
@@ -0,0 +1,174 @@
+GET /nostrnip5/api/v1/domains
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ [<domain_object>, ...]
+ Curl example
+ curl -X GET {{ request.base_url }}nostrnip5/api/v1/domains -H
+ "X-Api-Key: <invoice_key>"
+
+ GET /nostrnip5/api/v1/addresses
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ [<address_object>, ...]
+ Curl example
+ curl -X GET {{ request.base_url }}nostrnip5/api/v1/addresses -H
+ "X-Api-Key: <invoice_key>"
+
+ GET
+ /nostrnip5/api/v1/domain/{domain_id}
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ {domain_object}
+ Curl example
+ curl -X GET {{ request.base_url }}nostrnip5/api/v1/domain/{domain_id}
+ -H "X-Api-Key: <invoice_key>"
+
+ POST /nostrnip5/api/v1/domain
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ {domain_object}
+ Curl example
+ curl -X POST {{ request.base_url }}nostrnip5/api/v1/domain -H
+ "X-Api-Key: <invoice_key>"
+
+ POST
+ /nostrnip5/api/v1/domain/{domain_id}/address
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ {address_object}
+ Curl example
+ curl -X POST {{ request.base_url
+ }}nostrnip5/api/v1/domain/{domain_id}/address -H "X-Api-Key:
+ <invoice_key>"
+
+ POST
+ /invoices/api/v1/invoice/{invoice_id}/payments
+ Headers
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ {payment_object}
+ Curl example
+ curl -X POST {{ request.base_url
+ }}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key:
+ <invoice_key>"
+
+ GET
+ /nostrnip5/api/v1/domain/{domain_id}/payments/{payment_hash}
+ Headers
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ Curl example
+ curl -X GET {{ request.base_url
+ }}nostrnip5/api/v1/domain/{domain_id}/payments/{payment_hash} -H
+ "X-Api-Key: <invoice_key>"
+
+
+ You can use this page to change the public key associated with your + NIP-5 identity. +
++ Your current NIP-5 identity is {{ address.local_part }}@{{ domain.domain + }} with nostr public key {{ address.pubkey }}. +
+ +Input your new pubkey below to update it.
+ ++ Success! Your username is now active at {{ successData.local_part }}@{{ + domain }}. Please add this to your nostr profile accordingly. If you ever + need to rotate your keys, you can still keep your identity! +
+ ++ Bookmark this link: + {{ base_url }}nostrnip5/rotate/{{ domain_id }}/{{ + successData.address_id }} +
++ In case you ever need to change your pubkey, you can still keep this NIP-5 + identity. Just come back to the above linked page to change the pubkey + associated to your identity. +
+ {% endraw %} ++ You can use this page to get NIP-5 verified on the nostr protocol under + the {{ domain.domain }} domain. +
++ The current price is + {{ "{:0,.2f}".format(domain.amount / 100) }} {{ domain.currency }} + for a lifetime account. +
+ +After submitting payment, your address will be
+ +and will be tied to this nostr pubkey
+ +