use nostr_did_key::public_key_to_multikey;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DidDocument {
#[serde(rename = "@context")]
pub context: Vec<String>,
pub id: String,
#[serde(rename = "type")]
pub doc_type: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
#[serde(rename = "alsoKnownAs")]
pub also_known_as: Vec<String>,
#[serde(rename = "verificationMethod")]
pub verification_method: Vec<VerificationMethod>,
#[serde(rename = "authentication")]
pub authentication: Vec<String>,
#[serde(rename = "assertionMethod")]
pub assertion_method: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub service: Vec<Service>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<Profile>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub follows: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationMethod {
pub id: String,
#[serde(rename = "type")]
pub vm_type: String,
pub controller: String,
#[serde(rename = "publicKeyMultibase")]
pub public_key_multibase: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Service {
pub id: String,
#[serde(rename = "type")]
pub service_type: String,
#[serde(rename = "serviceEndpoint")]
pub service_endpoint: ServiceEndpoint,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ServiceEndpoint {
Single(String),
Multiple(Vec<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Profile {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nip05: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lud16: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<u64>,
}
const DEFAULT_RELAYS: &[&str] = &[
"wss://nos.lol",
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nostr.band",
"wss://purplepag.es",
];
pub struct DocumentBuilder {
relay_urls: Vec<String>,
profile: Option<Profile>,
also_known_as: Vec<String>,
follows: Vec<String>,
seen_relays: std::collections::HashSet<String>,
}
impl Default for DocumentBuilder {
fn default() -> Self {
Self::new()
}
}
impl DocumentBuilder {
pub fn new() -> Self {
let mut seen_relays = std::collections::HashSet::new();
let mut relay_urls = Vec::with_capacity(DEFAULT_RELAYS.len());
for relay in DEFAULT_RELAYS {
let normalized = relay.trim_end_matches('/').to_lowercase();
if seen_relays.insert(normalized.clone()) {
relay_urls.push(normalized);
}
}
Self {
relay_urls,
profile: None,
also_known_as: Vec::new(),
follows: Vec::new(),
seen_relays,
}
}
pub fn with_relay(mut self, relay: impl Into<String>) -> Self {
let normalized = relay.into().trim_end_matches('/').to_lowercase();
if self.seen_relays.insert(normalized.clone()) {
self.relay_urls.push(normalized);
}
self
}
pub fn with_relays(mut self, relays: Vec<String>) -> Self {
self.relay_urls.clear();
self.seen_relays.clear();
for relay in relays {
self = self.with_relay(relay);
}
self
}
pub fn with_profile(mut self, profile: Profile) -> Self {
self.profile = Some(profile);
self
}
pub fn with_also_known_as(mut self, identities: Vec<String>) -> Self {
self.also_known_as = identities;
self
}
pub fn with_follows(mut self, follows: Vec<String>) -> Self {
self.follows = follows;
self
}
pub fn build(&self, did: &str) -> Option<DidDocument> {
let pubkey_hex = extract_pubkey(did)?;
if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let multikey = public_key_to_multikey(pubkey_hex).ok()?;
let key_id = format!("{did}#key1");
let mut services = Vec::with_capacity(self.relay_urls.len());
for (i, relay) in self.relay_urls.iter().enumerate() {
let relay_id = if self.relay_urls.len() == 1 {
format!("{did}#relay")
} else {
format!("{did}#relay{}", i + 1)
};
services.push(Service {
id: relay_id,
service_type: "Relay".to_string(),
service_endpoint: ServiceEndpoint::Single(format!("{relay}/")),
});
}
Some(DidDocument {
context: vec![
"https://www.w3.org/ns/did/v1".to_string(),
"https://w3id.org/nostr/context".to_string(),
],
id: did.to_string(),
doc_type: "DIDNostr".to_string(),
also_known_as: self.also_known_as.clone(),
verification_method: vec![VerificationMethod {
id: key_id.clone(),
vm_type: "Multikey".to_string(),
controller: did.to_string(),
public_key_multibase: multikey,
}],
authentication: vec![key_id.clone()],
assertion_method: vec![key_id],
service: services,
profile: self.profile.clone(),
follows: self.follows.clone(),
})
}
}
fn extract_pubkey(did: &str) -> Option<&str> {
let prefix = "did:nostr:";
did.strip_prefix(prefix).filter(|p| p.len() == 64)
}
#[cfg(test)]
mod tests {
use super::*;
const SPEC_DID: &str =
"did:nostr:124c0fa99407182ece5a24fad9b7f6674902fc422843d3128d38a0afbee0fdd2";
const SPEC_MULTIKEY: &str =
"fe70102124c0fa99407182ece5a24fad9b7f6674902fc422843d3128d38a0afbee0fdd2";
#[test]
fn minimal_document_matches_spec() {
let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
assert_eq!(doc.id, SPEC_DID);
assert_eq!(doc.doc_type, "DIDNostr");
assert_eq!(doc.verification_method.len(), 1);
let vm = &doc.verification_method[0];
assert_eq!(vm.vm_type, "Multikey");
assert_eq!(vm.controller, SPEC_DID);
assert_eq!(vm.public_key_multibase, SPEC_MULTIKEY);
assert_eq!(vm.id, format!("{SPEC_DID}#key1"));
assert_eq!(doc.authentication, vec![format!("{SPEC_DID}#key1")]);
assert_eq!(doc.assertion_method, vec![format!("{SPEC_DID}#key1")]);
}
#[test]
fn minimal_document_has_no_optional_fields() {
let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
assert!(doc.also_known_as.is_empty());
assert!(doc.follows.is_empty());
assert!(doc.profile.is_none());
}
#[test]
fn enhanced_document_includes_default_relays() {
let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
assert_eq!(doc.service.len(), DEFAULT_RELAYS.len());
let relay_urls: Vec<&str> = doc
.service
.iter()
.filter_map(|s| match &s.service_endpoint {
ServiceEndpoint::Single(url) => Some(url.as_str()),
_ => None,
})
.collect();
assert!(relay_urls.iter().any(|u| u.contains("nos.lol")));
assert!(relay_urls.iter().any(|u| u.contains("relay.damus.io")));
assert!(relay_urls.iter().any(|u| u.contains("relay.primal.net")));
}
#[test]
fn enhanced_document_with_custom_relay() {
let doc = DocumentBuilder::new()
.with_relay("wss://custom.relay.com")
.build(SPEC_DID)
.unwrap();
let custom = doc
.service
.iter()
.find(|s| {
matches!(&s.service_endpoint, ServiceEndpoint::Single(url) if url.contains("custom.relay.com"))
})
.unwrap();
assert_eq!(custom.service_type, "Relay");
}
#[test]
fn custom_relays_replace_defaults() {
let doc = DocumentBuilder::new()
.with_relays(vec!["wss://sole.relay.com".to_string()])
.build(SPEC_DID)
.unwrap();
assert_eq!(doc.service.len(), 1);
match &doc.service[0].service_endpoint {
ServiceEndpoint::Single(url) => assert!(url.contains("sole.relay.com")),
_ => panic!("Expected single endpoint"),
}
}
#[test]
fn duplicate_relay_same_url_ignored() {
let doc = DocumentBuilder::new()
.with_relay("wss://relay.damus.io")
.with_relay("wss://relay.damus.io")
.with_relay("wss://relay.damus.io")
.build(SPEC_DID)
.unwrap();
let damus_count = doc
.service
.iter()
.filter(|s| {
matches!(&s.service_endpoint, ServiceEndpoint::Single(url) if url.contains("relay.damus.io"))
})
.count();
assert_eq!(damus_count, 1);
}
#[test]
fn duplicate_relay_trailing_slash_ignored() {
let doc = DocumentBuilder::new()
.with_relay("wss://relay.damus.io")
.with_relay("wss://relay.damus.io/")
.with_relay("WSS://RELAY.DAMUS.IO")
.build(SPEC_DID)
.unwrap();
let damus_count = doc
.service
.iter()
.filter(|s| {
matches!(&s.service_endpoint, ServiceEndpoint::Single(url) if url.contains("relay.damus.io"))
})
.count();
assert_eq!(damus_count, 1);
}
#[test]
fn duplicate_relay_via_defaults_and_explicit_add() {
let doc = DocumentBuilder::new()
.with_relay("wss://relay.damus.io") .build(SPEC_DID)
.unwrap();
assert_eq!(doc.service.len(), DEFAULT_RELAYS.len());
}
#[test]
fn complete_document_matches_spec_example() {
let profile = Profile {
name: Some("Alice".into()),
about: Some("Building the decentralized web".into()),
picture: Some("https://example.com/alice.jpg".into()),
nip05: None,
lud16: None,
website: None,
timestamp: Some(1737906600),
};
let doc = DocumentBuilder::new()
.with_relays(vec!["wss://relay.damus.io".to_string()])
.with_profile(profile)
.with_also_known_as(vec![
"https://alice.example.com/#me".into(),
"https://social.example.com/@alice".into(),
"at://alice.bsky.social".into(),
])
.with_follows(vec![
"did:nostr:32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245".into(),
"did:nostr:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184147700e3a8".into(),
])
.build(SPEC_DID)
.unwrap();
let p = doc.profile.unwrap();
assert_eq!(p.name.unwrap(), "Alice");
assert_eq!(p.about.unwrap(), "Building the decentralized web");
assert_eq!(p.timestamp.unwrap(), 1737906600);
assert_eq!(doc.also_known_as.len(), 3);
assert!(doc
.also_known_as
.contains(&"at://alice.bsky.social".to_string()));
assert_eq!(doc.follows.len(), 2);
assert!(doc.follows[0].starts_with("did:nostr:"));
assert_eq!(
doc.verification_method[0].public_key_multibase,
SPEC_MULTIKEY
);
}
#[test]
fn document_has_required_jsonld_contexts() {
let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
assert!(doc
.context
.contains(&"https://www.w3.org/ns/did/v1".to_string()));
assert!(doc
.context
.contains(&"https://w3id.org/nostr/context".to_string()));
}
#[test]
fn document_serializes_valid_jsonld() {
let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(json["id"], SPEC_DID);
assert_eq!(json["type"], "DIDNostr");
assert!(json["@context"].is_array());
let vm = &json["verificationMethod"][0];
assert_eq!(vm["type"], "Multikey");
assert_eq!(vm["publicKeyMultibase"], SPEC_MULTIKEY);
assert_eq!(vm["controller"], SPEC_DID);
assert!(json["authentication"].is_array());
assert!(json["assertionMethod"].is_array());
}
#[test]
fn document_roundtrip_json() {
let doc = DocumentBuilder::new()
.with_profile(Profile {
name: Some("Test".into()),
about: Some("Roundtrip test".into()),
..Default::default()
})
.with_also_known_as(vec!["https://example.com".into()])
.with_follows(vec![
"did:nostr:abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1".into(),
])
.build(SPEC_DID)
.unwrap();
let json = serde_json::to_string_pretty(&doc).unwrap();
let parsed: DidDocument = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, doc.id);
assert_eq!(parsed.doc_type, doc.doc_type);
assert_eq!(parsed.profile.unwrap().name.unwrap(), "Test");
assert_eq!(parsed.also_known_as.len(), 1);
assert_eq!(parsed.follows.len(), 1);
}
#[test]
fn builder_rejects_invalid_did() {
assert!(DocumentBuilder::new().build("did:nostr:tooshort").is_none());
assert!(DocumentBuilder::new().build("did:key:abc123").is_none());
assert!(DocumentBuilder::new().build("not-a-did").is_none());
}
#[test]
fn builder_rejects_non_hex_pubkey() {
let bad = "did:nostr:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg";
assert!(DocumentBuilder::new().build(bad).is_none());
}
#[test]
fn empty_optional_fields_omitted_from_json() {
let doc = DocumentBuilder::new().build(SPEC_DID).unwrap();
let json = serde_json::to_string_pretty(&doc).unwrap();
assert!(!json.contains("\"alsoKnownAs\""));
assert!(!json.contains("\"follows\""));
assert!(!json.contains("\"profile\""));
}
#[test]
fn profile_default_is_all_none() {
let p = Profile::default();
assert!(p.name.is_none());
assert!(p.about.is_none());
assert!(p.picture.is_none());
assert!(p.nip05.is_none());
assert!(p.lud16.is_none());
assert!(p.website.is_none());
assert!(p.timestamp.is_none());
}
}