diff --git a/.gitignore b/.gitignore
index dda7278..81d1395 100644
--- a/.gitignore
+++ b/.gitignore
@@ -162,3 +162,4 @@ cython_debug/
#.idea/
.quartenv
+.vscode/
diff --git a/nwc-frontend/src/hooks/useAppInfo.ts b/nwc-frontend/src/hooks/useAppInfo.ts
index 7b28e29..e5a20f5 100644
--- a/nwc-frontend/src/hooks/useAppInfo.ts
+++ b/nwc-frontend/src/hooks/useAppInfo.ts
@@ -8,7 +8,8 @@ export const fetchAppInfo = async (clientId: string) => {
return {
clientId: appInfo.clientId,
name: appInfo.name,
- verified: appInfo.verified === "VERIFIED",
+ nip05Verified: appInfo.nip05Verification == "VERIFIED",
+ nip68Verification: appInfo.nip68Verification,
domain: appInfo.domain,
avatar: appInfo.avatar,
};
diff --git a/nwc-frontend/src/permissions/PermissionsPage.tsx b/nwc-frontend/src/permissions/PermissionsPage.tsx
index 6b2cd23..16a4f5a 100644
--- a/nwc-frontend/src/permissions/PermissionsPage.tsx
+++ b/nwc-frontend/src/permissions/PermissionsPage.tsx
@@ -198,7 +198,8 @@ export const PermissionsPage = () => {
{appInfo && (
<>
{appInfo.name}
- {appInfo.verified && (
+ {appInfo.nip05Verified && (
+ // TODO: Add NIP-68 verification status
diff --git a/nwc-frontend/src/permissions/PersonalizePage.tsx b/nwc-frontend/src/permissions/PersonalizePage.tsx
index 5111bbf..3a3e970 100644
--- a/nwc-frontend/src/permissions/PersonalizePage.tsx
+++ b/nwc-frontend/src/permissions/PersonalizePage.tsx
@@ -116,7 +116,8 @@ export const PersonalizePage = ({
{appInfo && (
<>
{appInfo.name}
- {appInfo.verified && (
+ {appInfo.nip05Verified && (
+ // TODO: Add NIP-68 verification status
diff --git a/nwc-frontend/src/types/AppInfo.ts b/nwc-frontend/src/types/AppInfo.ts
index e0d4500..9cc97c2 100644
--- a/nwc-frontend/src/types/AppInfo.ts
+++ b/nwc-frontend/src/types/AppInfo.ts
@@ -1,7 +1,14 @@
export interface AppInfo {
clientId: string;
name: string;
- verified: boolean;
+ nip05Verified: boolean;
domain: string;
avatar: string;
+ nip68Verification?:
+ | {
+ status: string;
+ authorityName: string;
+ authorityPubKey: string;
+ }
+ | null;
}
diff --git a/nwc_backend/api_handlers/client_app_lookup_handler.py b/nwc_backend/api_handlers/client_app_lookup_handler.py
index fa07d05..76ba335 100644
--- a/nwc_backend/api_handlers/client_app_lookup_handler.py
+++ b/nwc_backend/api_handlers/client_app_lookup_handler.py
@@ -15,17 +15,26 @@ async def get_client_app() -> Response:
client_app_info = await look_up_client_app_identity(client_id)
if not client_app_info:
return Response("Client app not found", status=404)
+
+ nip68_verification_json = None
+ if client_app_info.app_authority_verification:
+ nip68_verification_json = {
+ "authorityName": client_app_info.app_authority_verification.authority_name,
+ "authorityPublicKey": client_app_info.app_authority_verification.authority_pubkey,
+ "status": client_app_info.app_authority_verification.status.value,
+ }
return Response(
json.dumps(
{
"clientId": client_id,
"name": client_app_info.display_name,
- "verified": (
+ "nip05Verification": (
client_app_info.nip05.verification_status.value
if client_app_info.nip05
else None
),
+ "nip68Verification": nip68_verification_json,
"avatar": client_app_info.image_url,
"domain": (
client_app_info.nip05.domain if client_app_info.nip05 else None
diff --git a/nwc_backend/configs/local_dev.py b/nwc_backend/configs/local_dev.py
index 18c6767..e69be4a 100644
--- a/nwc_backend/configs/local_dev.py
+++ b/nwc_backend/configs/local_dev.py
@@ -35,3 +35,8 @@
"execute_quote",
"pay_to_address",
]
+
+# NIP-68 client app authorities which can verify app identity events.
+CLIENT_APP_AUTHORITIES = [
+ # "nprofile1qqstse98yvaykl3k2yez3732tmsc9vaq8c3uhex0s4qp4dl8fczmp9spp4mhxue69uhkummn9ekx7mq26saje" # Lightspark at nos.lol
+]
diff --git a/nwc_backend/configs/local_docker.py b/nwc_backend/configs/local_docker.py
index e18be99..b2e69c5 100644
--- a/nwc_backend/configs/local_docker.py
+++ b/nwc_backend/configs/local_docker.py
@@ -34,3 +34,8 @@
"execute_quote",
"pay_to_address",
]
+
+# NIP-68 client app authorities which can verify app identity events.
+CLIENT_APP_AUTHORITIES = [
+ # "nprofile1qqstse98yvaykl3k2yez3732tmsc9vaq8c3uhex0s4qp4dl8fczmp9spp4mhxue69uhkummn9ekx7mq26saje" # Lightspark at nos.lol
+]
diff --git a/nwc_backend/configs/testing.py b/nwc_backend/configs/testing.py
index c18c23d..51e8f51 100644
--- a/nwc_backend/configs/testing.py
+++ b/nwc_backend/configs/testing.py
@@ -30,3 +30,7 @@
"execute_quote",
"pay_to_address",
]
+
+CLIENT_APP_AUTHORITIES = [
+ "nprofile1qqstg4syz8qyk9xeyp5j7haaw9nz67a6wzt80tmu5vn5g4ckpxlagvqpp4mhxue69uhkummn9ekx7mqnegwyj" # Fake authority at nos.lol
+]
diff --git a/nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py b/nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py
new file mode 100644
index 0000000..72d7355
--- /dev/null
+++ b/nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py
@@ -0,0 +1,232 @@
+import json
+from typing import List
+from unittest.mock import AsyncMock, patch
+
+from nostr_sdk import (
+ EventBuilder,
+ EventSource,
+ Filter,
+ Keys,
+ Kind,
+ KindEnum,
+ Metadata,
+ Tag,
+)
+from quart.app import QuartClient
+
+from nwc_backend.nostr.__tests__.fake_nostr_client import FakeNostrClient
+from nwc_backend.nostr.client_app_identity_lookup import (
+ Nip05,
+ Nip05VerificationStatus,
+ Nip68VerificationStatus,
+ look_up_client_app_identity,
+)
+
+CLIENT_PUBKEY = "npub13msd7fakpaqerq036kk0c6pf9effz5nn5yk6nqj4gtwtzr5l6fxq64z8x5"
+CLIENT_PRIVKEY = "nsec1e792rulwmsjanw783x39r8vcm23c2hwcandwahaw6wh39rfydshqxhfm7x"
+CLIENT_ID = f"{CLIENT_PUBKEY} wss://nos.lol"
+
+AUTHORITY_PUBKEY = "npub1k3tqgywqfv2djgrf9a0m6utx94am5uykw7hhege8g3t3vzdl6scq6ewt9d"
+AUTHORITY_PRIVKEY = "nsec1gn4guugvc8656dwqs3j486leffaepx2mvpym7qkh2k2vuuextvtsnv6x76"
+
+
+async def test_unregistered(test_client: QuartClient) -> None:
+ fake_client = FakeNostrClient()
+
+ async def on_get_events(filters: List[Filter], source: EventSource):
+ return []
+
+ fake_client.on_get_events = on_get_events
+ identity = await look_up_client_app_identity(
+ client_id=CLIENT_ID,
+ nostr_client_factory=lambda: fake_client,
+ )
+
+ assert identity is None
+
+
+@patch.object(Nip05, "verify", new_callable=AsyncMock)
+async def test_only_kind0(
+ mock_verify_nip05: AsyncMock, test_client: QuartClient
+) -> None:
+ mock_verify_nip05.return_value = Nip05VerificationStatus.VERIFIED
+ fake_client = FakeNostrClient()
+
+ async def on_get_events(filters: List[Filter], source: EventSource):
+ return [
+ EventBuilder.metadata(
+ Metadata()
+ .set_name("Blue Drink")
+ .set_nip05("_@bluedrink.com")
+ .set_picture("https://bluedrink.com/image.png")
+ ).to_event(Keys.parse(CLIENT_PRIVKEY))
+ ]
+
+ fake_client.on_get_events = on_get_events
+ identity = await look_up_client_app_identity(
+ client_id=CLIENT_ID,
+ nostr_client_factory=lambda: fake_client,
+ )
+
+ mock_verify_nip05.assert_called_once()
+
+ assert identity is not None
+ assert identity.name == "Blue Drink"
+ assert identity.nip05.verification_status == Nip05VerificationStatus.VERIFIED
+ assert identity.image_url == "https://bluedrink.com/image.png"
+
+
+@patch.object(Nip05, "verify", new_callable=AsyncMock)
+async def test_only_kind13195_no_label(
+ mock_verify_nip05: AsyncMock, test_client: QuartClient
+) -> None:
+ mock_verify_nip05.return_value = Nip05VerificationStatus.VERIFIED
+ fake_client = FakeNostrClient()
+
+ async def on_get_events(filters: List[Filter], source: EventSource):
+ return [
+ EventBuilder(
+ kind=Kind(13195),
+ content=json.dumps(
+ {
+ "name": "Green Drink",
+ "nip05": "_@greendrink.com",
+ "image": "https://greendrink.com/image.png",
+ "allowed_redirect_urls": ["https://greendrink.com/callback"],
+ }
+ ),
+ tags=[],
+ ).to_event(Keys.parse(CLIENT_PRIVKEY))
+ ]
+
+ fake_client.on_get_events = on_get_events
+ async with test_client.app.app_context():
+ identity = await look_up_client_app_identity(
+ client_id=CLIENT_ID,
+ nostr_client_factory=lambda: fake_client,
+ )
+
+ mock_verify_nip05.assert_called_once()
+
+ assert identity is not None
+ assert identity.name == "Green Drink"
+ assert identity.nip05.verification_status == Nip05VerificationStatus.VERIFIED
+ assert identity.image_url == "https://greendrink.com/image.png"
+ assert identity.allowed_redirect_urls == ["https://greendrink.com/callback"]
+ assert identity.app_authority_verification is None
+
+
+@patch.object(Nip05, "verify", new_callable=AsyncMock)
+async def test_only_kind13195_with_label(
+ mock_verify_nip05: AsyncMock, test_client: QuartClient
+) -> None:
+ mock_verify_nip05.return_value = Nip05VerificationStatus.VERIFIED
+ fake_client = FakeNostrClient()
+
+ id_event = EventBuilder(
+ kind=Kind(13195),
+ content=json.dumps(
+ {
+ "name": "Green Drink",
+ "nip05": "_@greendrink.com",
+ "image": "https://greendrink.com/image.png",
+ "allowed_redirect_urls": ["https://greendrink.com/callback"],
+ }
+ ),
+ tags=[],
+ ).to_event(Keys.parse(CLIENT_PRIVKEY))
+
+ async def on_get_events(filters: List[Filter], source: EventSource):
+ if Kind(13195) in filters[0].as_record().kinds:
+ return [id_event]
+ if Kind.from_enum(KindEnum.LABEL()) in filters[0].as_record().kinds:
+ return [
+ EventBuilder.label("nip68.client_app", ["verified", "nip68.client_app"])
+ .add_tags([Tag.event(id_event.id())])
+ .to_event(Keys.parse(AUTHORITY_PRIVKEY)),
+ EventBuilder.metadata(
+ Metadata().set_name("Important Authority")
+ ).to_event(Keys.parse(AUTHORITY_PRIVKEY)),
+ ]
+ return []
+
+ fake_client.on_get_events = on_get_events
+ async with test_client.app.app_context():
+ identity = await look_up_client_app_identity(
+ client_id=CLIENT_ID,
+ nostr_client_factory=lambda: fake_client,
+ )
+
+ mock_verify_nip05.assert_called_once()
+
+ assert identity is not None
+ assert identity.name == "Green Drink"
+ assert identity.nip05.verification_status == Nip05VerificationStatus.VERIFIED
+ assert identity.image_url == "https://greendrink.com/image.png"
+ assert identity.allowed_redirect_urls == ["https://greendrink.com/callback"]
+ assert identity.app_authority_verification is not None
+ assert identity.app_authority_verification.authority_name == "Important Authority"
+ assert (
+ identity.app_authority_verification.authority_pubkey
+ == Keys.parse(AUTHORITY_PRIVKEY).public_key().to_hex()
+ )
+ assert (
+ identity.app_authority_verification.status == Nip68VerificationStatus.VERIFIED
+ )
+
+
+@patch.object(Nip05, "verify", new_callable=AsyncMock)
+async def test_kind13195_with_revoked_label(
+ mock_verify_nip05: AsyncMock, test_client: QuartClient
+) -> None:
+ mock_verify_nip05.return_value = Nip05VerificationStatus.VERIFIED
+ fake_client = FakeNostrClient()
+
+ id_event = EventBuilder(
+ kind=Kind(13195),
+ content=json.dumps(
+ {
+ "name": "Yellow Drink",
+ "nip05": "_@yellowdrink.com",
+ "image": "https://yellowdrink.com/image.png",
+ "allowed_redirect_urls": ["https://yellowdrink.com/callback"],
+ }
+ ),
+ tags=[],
+ ).to_event(Keys.parse(CLIENT_PRIVKEY))
+
+ async def on_get_events(filters: List[Filter], source: EventSource):
+ if Kind(13195) in filters[0].as_record().kinds:
+ return [id_event]
+ if Kind.from_enum(KindEnum.LABEL()) in filters[0].as_record().kinds:
+ return [
+ EventBuilder.label("nip68.client_app", ["revoked", "nip68.client_app"])
+ .add_tags([Tag.event(id_event.id())])
+ .to_event(Keys.parse(AUTHORITY_PRIVKEY)),
+ EventBuilder.metadata(
+ Metadata().set_name("Important Authority")
+ ).to_event(Keys.parse(AUTHORITY_PRIVKEY)),
+ ]
+ return []
+
+ fake_client.on_get_events = on_get_events
+ async with test_client.app.app_context():
+ identity = await look_up_client_app_identity(
+ client_id=CLIENT_ID,
+ nostr_client_factory=lambda: fake_client,
+ )
+
+ mock_verify_nip05.assert_called_once()
+
+ assert identity is not None
+ assert identity.name == "Yellow Drink"
+ assert identity.nip05.verification_status == Nip05VerificationStatus.VERIFIED
+ assert identity.image_url == "https://yellowdrink.com/image.png"
+ assert identity.allowed_redirect_urls == ["https://yellowdrink.com/callback"]
+ assert identity.app_authority_verification is not None
+ assert identity.app_authority_verification.authority_name == "Important Authority"
+ assert (
+ identity.app_authority_verification.authority_pubkey
+ == Keys.parse(AUTHORITY_PRIVKEY).public_key().to_hex()
+ )
+ assert identity.app_authority_verification.status == Nip68VerificationStatus.REVOKED
diff --git a/nwc_backend/nostr/__tests__/fake_nostr_client.py b/nwc_backend/nostr/__tests__/fake_nostr_client.py
new file mode 100644
index 0000000..5e2df44
--- /dev/null
+++ b/nwc_backend/nostr/__tests__/fake_nostr_client.py
@@ -0,0 +1,45 @@
+from typing import Any, Callable, Coroutine, List
+
+from nostr_sdk import Client
+from nostr_sdk.nostr_ffi import Event, Filter
+from nostr_sdk.nostr_sdk_ffi import EventSource, Output, SendEventOutput
+
+
+class FakeNostrClient(Client):
+ def __init__(self, *args, **kwargs):
+ self.connected = False
+ self.sent_events = []
+ self.last_filters = []
+ self.added_relays: List[str] = []
+ self.on_get_events: Callable[
+ [List[Filter], EventSource], Coroutine[Any, Any, List[Event]]
+ ] = None
+
+ async def connect(self):
+ self.connected = True
+
+ async def disconnect(self):
+ self.connected = False
+
+ async def add_relay(self, url: str) -> bool:
+ self.added_relays.append(url)
+ return True
+
+ async def add_read_relay(self, url: str) -> bool:
+ self.added_relays.append(url)
+ return True
+
+ async def get_events_of(
+ self, filters: List[Filter], source: EventSource
+ ) -> List[Event]:
+ self.last_filters = filters
+ if self.on_get_events:
+ return await self.on_get_events(filters, source)
+ return []
+
+ async def send_event(self, event: Event) -> SendEventOutput:
+ self.sent_events.append(event)
+ return SendEventOutput(
+ id=event.id(),
+ output=Output(success=self.added_relays, failed={}),
+ )
diff --git a/nwc_backend/nostr/client_app_identity_lookup.py b/nwc_backend/nostr/client_app_identity_lookup.py
index d0589be..4056c9b 100644
--- a/nwc_backend/nostr/client_app_identity_lookup.py
+++ b/nwc_backend/nostr/client_app_identity_lookup.py
@@ -5,10 +5,11 @@
from dataclasses import dataclass
from datetime import timedelta
from enum import Enum
-from typing import Optional
+from typing import Callable, List, Optional
from urllib.parse import urlparse
from nostr_sdk import (
+ Alphabet,
Client,
Event,
EventSource,
@@ -16,9 +17,13 @@
Kind,
KindEnum,
Metadata,
+ Nip19Profile,
PublicKey,
+ SingleLetterTag,
+ TagKind,
verify_nip05,
)
+from quart import current_app
from nwc_backend.exceptions import InvalidClientIdException
@@ -29,6 +34,20 @@ class Nip05VerificationStatus(Enum):
UNKNOWN = "UNKNOWN"
+class Nip68VerificationStatus(Enum):
+ VERIFIED = "VERIFIED"
+ REVOKED = "REVOKED"
+ NONE = "NONE"
+
+
+@dataclass
+class Nip68Verification:
+ status: Nip68VerificationStatus
+ authority_pubkey: str
+ authority_name: str
+ revoked_at: Optional[int]
+
+
@dataclass
class Nip05:
domain: str
@@ -75,6 +94,7 @@ class ClientAppInfo:
nip05: Optional[Nip05] = None
display_name: Optional[str] = None
allowed_redirect_urls: Optional[list[str]] = None
+ app_authority_verification: Optional[Nip68Verification] = None
def is_redirect_url_allowed(self, redirect_url: str) -> bool:
if not self.allowed_redirect_urls:
@@ -96,7 +116,10 @@ def is_redirect_url_allowed(self, redirect_url: str) -> bool:
return False
-async def look_up_client_app_identity(client_id: str) -> Optional[ClientAppInfo]:
+async def look_up_client_app_identity(
+ client_id: str,
+ nostr_client_factory: Callable[[], Client] = Client,
+) -> Optional[ClientAppInfo]:
try:
[client_pubkey, relay_url] = client_id.split(" ")
except ValueError:
@@ -112,7 +135,7 @@ async def look_up_client_app_identity(client_id: str) -> Optional[ClientAppInfo]
except Exception:
raise InvalidClientIdException("Invalid public key in client_id.")
- client = Client()
+ client = nostr_client_factory()
await client.add_relay(relay_url)
await client.connect()
@@ -136,7 +159,10 @@ async def look_up_client_app_identity(client_id: str) -> Optional[ClientAppInfo]
return None
client_app_info = await _look_up_from_kind_13195(
- events=events, client_pubkey=client_pubkey, relay_url=relay_url
+ events=events,
+ client_pubkey=client_pubkey,
+ relay_url=relay_url,
+ nostr_client_factory=nostr_client_factory,
)
if client_app_info:
logging.debug("Found 13195 for client_id %s", client_id)
@@ -149,25 +175,34 @@ async def look_up_client_app_identity(client_id: str) -> Optional[ClientAppInfo]
async def _look_up_from_kind_13195(
- events: list[Event], client_pubkey: PublicKey, relay_url: str
+ events: List[Event],
+ client_pubkey: PublicKey,
+ relay_url: str,
+ nostr_client_factory: Callable[[], Client],
) -> Optional[ClientAppInfo]:
- events_13195 = [event for event in events if event.kind().as_u16 == 13195]
- if events_13195:
- event = events_13195[0]
- event.verify_signature()
- content = json.loads(event.content())
- return ClientAppInfo(
- pubkey=client_pubkey,
- identity_relay=relay_url,
- name=content.get("name"),
- image_url=content.get("image"),
- nip05=await Nip05.from_nip05_address(
- nip05_address=content.get("nip05"), pubkey=client_pubkey
- ),
- display_name=content.get("name"),
- allowed_redirect_urls=content.get("allowed_redirect_urls"),
- )
- return None
+ events_13195 = [event for event in events if event.kind().as_u16() == 13195]
+ if not events_13195:
+ return None
+
+ event = events_13195[0]
+ if not event.verify_signature():
+ raise InvalidClientIdException("Invalid signature in 13195 event.")
+
+ content = json.loads(event.content())
+ return ClientAppInfo(
+ pubkey=client_pubkey,
+ identity_relay=relay_url,
+ name=content.get("name"),
+ image_url=content.get("image"),
+ nip05=await Nip05.from_nip05_address(
+ nip05_address=content.get("nip05"), pubkey=client_pubkey
+ ),
+ display_name=content.get("name"),
+ allowed_redirect_urls=content.get("allowed_redirect_urls"),
+ app_authority_verification=await _check_app_authorities(
+ event, nostr_client_factory
+ ),
+ )
async def _look_up_from_kind_0(
@@ -176,7 +211,10 @@ async def _look_up_from_kind_0(
[event] = [
event for event in events if event.kind().as_enum() == KindEnum.METADATA()
]
- event.verify_signature()
+
+ if not event.verify_signature():
+ raise InvalidClientIdException("Invalid signature in metadata event.")
+
metadata = Metadata.from_json(event.content())
return ClientAppInfo(
pubkey=client_pubkey,
@@ -188,4 +226,109 @@ async def _look_up_from_kind_0(
),
display_name=metadata.get_display_name(),
allowed_redirect_urls=None,
+ app_authority_verification=None,
+ )
+
+
+async def _check_app_authorities(
+ identity_event: Event,
+ nostr_client_factory: Callable[[], Client] = Client,
+) -> Optional[Nip68Verification]:
+ registration_authorities = current_app.config.get("CLIENT_APP_AUTHORITIES")
+ if not registration_authorities:
+ return None
+
+ try:
+ authority_nprofiles = [
+ Nip19Profile.from_bech32(authority)
+ for authority in registration_authorities
+ ]
+ except Exception:
+ logging.exception("Invalid NIP19 profile in CLIENT_APP_AUTHORITIES.")
+ return None
+
+ client = nostr_client_factory()
+ for nprofile in authority_nprofiles:
+ for relay in nprofile.relays():
+ await client.add_read_relay(relay)
+
+ authority_pubkeys = [nprofile.public_key() for nprofile in authority_nprofiles]
+
+ await client.connect()
+ label_filter = (
+ Filter()
+ .authors(authority_pubkeys)
+ .kinds(
+ [
+ Kind.from_enum(KindEnum.LABEL()), # pyre-ignore[6]
+ Kind.from_enum(KindEnum.METADATA()), # pyre-ignore[6]
+ ]
+ )
+ .custom_tag(SingleLetterTag.uppercase(Alphabet.L), ["nip68.client_app"])
+ .event(identity_event.id())
+ )
+ metadata_filter = (
+ Filter()
+ .authors(authority_pubkeys)
+ .kinds(
+ [
+ Kind.from_enum(KindEnum.METADATA()), # pyre-ignore[6]
+ ]
+ )
+ )
+ source = EventSource.relays(timeout=timedelta(seconds=10))
+ verification_and_metadata_events = await client.get_events_of(
+ filters=[label_filter, metadata_filter], source=source
)
+ await client.disconnect()
+
+ verification_events = [
+ event
+ for event in verification_and_metadata_events
+ if event.kind().as_enum() == KindEnum.LABEL()
+ ]
+
+ metadata_events_by_pubkey = {
+ event.author().to_hex(): event
+ for event in verification_and_metadata_events
+ if event.kind().as_enum() == KindEnum.METADATA()
+ }
+
+ def authority_name_for_pubkey(pubkey: str) -> str:
+ authority_name = "Default App Authority"
+ if metadata_events_by_pubkey.get(pubkey):
+ authority_name = (
+ Metadata.from_json(
+ metadata_events_by_pubkey[pubkey].content()
+ ).get_name()
+ or authority_name
+ )
+ return authority_name
+
+ # If any verifications were revoked, prioritize that status above all others.
+ for event in verification_events:
+ status = event.get_tag_content(
+ TagKind.SINGLE_LETTER(SingleLetterTag.lowercase(Alphabet.L))
+ )
+ if status and status.lower() == "revoked":
+ return Nip68Verification(
+ status=Nip68VerificationStatus.REVOKED,
+ authority_pubkey=event.author().to_hex(),
+ authority_name=authority_name_for_pubkey(event.author().to_hex()),
+ revoked_at=event.created_at(),
+ )
+
+ # If any verifications were confirmed, return the first one.
+ for event in verification_events:
+ status = event.get_tag_content(
+ TagKind.SINGLE_LETTER(SingleLetterTag.lowercase(Alphabet.L))
+ )
+ if status and status.lower() == "verified":
+ return Nip68Verification(
+ status=Nip68VerificationStatus.VERIFIED,
+ authority_pubkey=event.author().to_hex(),
+ authority_name=authority_name_for_pubkey(event.author().to_hex()),
+ revoked_at=None,
+ )
+
+ return None