use crate::auth::certificates::Certificate;
use crate::auth::VerifiableCertificate;
use crate::overlay::{
LookupAnswer, LookupQuestion, LookupResolver, LookupResolverConfig, OutputListItem,
TopicBroadcaster, TopicBroadcasterConfig,
};
use crate::primitives::bsv::sighash::{
parse_transaction, SighashParams, SIGHASH_ALL, SIGHASH_FORKID,
};
use crate::primitives::bsv::tx_signature::TransactionSignature;
use crate::primitives::{from_base64, sha256, to_hex, PrivateKey, PublicKey, Signature};
use crate::script::templates::PushDrop;
use crate::transaction::{Beef, Broadcaster, Transaction};
use crate::wallet::{
Counterparty, CreateActionArgs, CreateActionInput, CreateActionOptions, CreateActionOutput,
CreateSignatureArgs, Outpoint, ProtoWallet, Protocol, ProveCertificateArgs, SignActionArgs,
SignActionOptions, SignActionSpend, WalletCertificate, WalletInterface,
};
use crate::{Error, Result};
use super::contacts::ContactsManager;
#[cfg(test)]
use super::types::BroadcastSuccess;
use super::types::{
BroadcastFailure, BroadcastResult, CertificateFieldNameUnder50Bytes, CertifierInfo, Contact,
ContactsManagerConfig, DefaultIdentityValues, DisplayableIdentity, IdentityCertificate,
IdentityClientConfig, IdentityQuery, IdentityResolutionResult, KnownCertificateType,
StaticAvatarUrls, DEFAULT_SOCIALCERT_CERTIFIER,
};
use std::collections::HashMap;
use std::sync::Arc;
pub struct IdentityClient<W: WalletInterface> {
wallet: W,
config: IdentityClientConfig,
resolver: Arc<LookupResolver>,
contacts_manager: ContactsManager<W>,
}
impl<W: WalletInterface> IdentityClient<W> {
pub fn parse_identity(cert: &IdentityCertificate) -> DisplayableIdentity {
let type_id = cert.type_base64();
let known_type = KnownCertificateType::from_type_id(&type_id);
let decrypted = &cert.decrypted_fields;
let certifier_info = &cert.certifier_info;
let (name, avatar_url, badge_label, badge_icon_url, badge_click_url) = match known_type {
Some(KnownCertificateType::XCert) => {
let name = decrypted
.get("userName")
.cloned()
.unwrap_or_else(|| DefaultIdentityValues::NAME.to_string());
let avatar = decrypted
.get("profilePhoto")
.cloned()
.unwrap_or_else(|| DefaultIdentityValues::AVATAR_URL.to_string());
let badge = format!("X account certified by {}", certifier_info.name);
(
name,
avatar,
badge,
certifier_info.icon_url.clone(),
"https://socialcert.net".to_string(),
)
}
Some(KnownCertificateType::DiscordCert) => {
let name = decrypted
.get("userName")
.cloned()
.unwrap_or_else(|| DefaultIdentityValues::NAME.to_string());
let avatar = decrypted
.get("profilePhoto")
.cloned()
.unwrap_or_else(|| DefaultIdentityValues::AVATAR_URL.to_string());
let badge = format!("Discord account certified by {}", certifier_info.name);
(
name,
avatar,
badge,
certifier_info.icon_url.clone(),
"https://socialcert.net".to_string(),
)
}
Some(KnownCertificateType::EmailCert) => {
let name = decrypted
.get("email")
.cloned()
.unwrap_or_else(|| DefaultIdentityValues::NAME.to_string());
let badge = format!("Email certified by {}", certifier_info.name);
(
name,
StaticAvatarUrls::EMAIL.to_string(),
badge,
certifier_info.icon_url.clone(),
"https://socialcert.net".to_string(),
)
}
Some(KnownCertificateType::PhoneCert) => {
let name = decrypted
.get("phoneNumber")
.cloned()
.unwrap_or_else(|| DefaultIdentityValues::NAME.to_string());
let badge = format!("Phone certified by {}", certifier_info.name);
(
name,
StaticAvatarUrls::PHONE.to_string(),
badge,
certifier_info.icon_url.clone(),
"https://socialcert.net".to_string(),
)
}
Some(KnownCertificateType::IdentiCert) => {
let first = decrypted.get("firstName").cloned().unwrap_or_default();
let last = decrypted.get("lastName").cloned().unwrap_or_default();
let name = format!("{} {}", first, last).trim().to_string();
let name = if name.is_empty() {
DefaultIdentityValues::NAME.to_string()
} else {
name
};
let avatar = decrypted
.get("profilePhoto")
.cloned()
.unwrap_or_else(|| DefaultIdentityValues::AVATAR_URL.to_string());
let badge = format!("Government ID certified by {}", certifier_info.name);
(
name,
avatar,
badge,
certifier_info.icon_url.clone(),
"https://identicert.me".to_string(),
)
}
Some(KnownCertificateType::Registrant) => {
let name = decrypted
.get("name")
.cloned()
.unwrap_or_else(|| DefaultIdentityValues::NAME.to_string());
let avatar = decrypted
.get("icon")
.cloned()
.unwrap_or_else(|| DefaultIdentityValues::AVATAR_URL.to_string());
let badge = format!("Entity certified by {}", certifier_info.name);
(
name,
avatar,
badge,
certifier_info.icon_url.clone(),
"https://projectbabbage.com/docs/registrant".to_string(),
)
}
Some(KnownCertificateType::CoolCert) => {
let is_cool = decrypted.get("cool").map(|v| v == "true").unwrap_or(false);
let name = if is_cool {
"Cool Person!".to_string()
} else {
"Not cool!".to_string()
};
(
name,
DefaultIdentityValues::AVATAR_URL.to_string(),
DefaultIdentityValues::BADGE_LABEL.to_string(),
DefaultIdentityValues::BADGE_ICON_URL.to_string(),
DefaultIdentityValues::BADGE_CLICK_URL.to_string(),
)
}
Some(KnownCertificateType::Anyone) => (
"Anyone".to_string(),
StaticAvatarUrls::ANYONE.to_string(),
"Represents the ability for anyone to access this information.".to_string(),
DefaultIdentityValues::BADGE_ICON_URL.to_string(),
"https://projectbabbage.com/docs/anyone-identity".to_string(),
),
Some(KnownCertificateType::SelfCert) => (
"You".to_string(),
StaticAvatarUrls::SELF.to_string(),
"Represents your ability to access this information.".to_string(),
DefaultIdentityValues::BADGE_ICON_URL.to_string(),
"https://projectbabbage.com/docs/self-identity".to_string(),
),
None => {
Self::try_parse_generic_identity(decrypted, certifier_info)
}
};
let subject = cert.subject_hex();
let abbreviated_key = if subject.len() > 10 {
format!("{}...", &subject[..10])
} else {
subject.clone()
};
DisplayableIdentity {
name,
avatar_url,
identity_key: subject,
abbreviated_key,
badge_icon_url,
badge_label,
badge_click_url,
}
}
fn try_parse_generic_identity(
decrypted: &HashMap<String, String>,
certifier_info: &CertifierInfo,
) -> (String, String, String, String, String) {
let name = decrypted
.get("name")
.or_else(|| decrypted.get("userName"))
.or_else(|| {
let first = decrypted.get("firstName");
let last = decrypted.get("lastName");
if first.is_some() || last.is_some() {
None } else {
decrypted.get("email")
}
})
.cloned();
let name = name.unwrap_or_else(|| {
let first = decrypted.get("firstName").cloned().unwrap_or_default();
let last = decrypted.get("lastName").cloned().unwrap_or_default();
let full = format!("{} {}", first, last).trim().to_string();
if full.is_empty() {
DefaultIdentityValues::NAME.to_string()
} else {
full
}
});
let avatar = decrypted
.get("profilePhoto")
.or_else(|| decrypted.get("avatar"))
.or_else(|| decrypted.get("icon"))
.or_else(|| decrypted.get("photo"))
.cloned()
.unwrap_or_else(|| DefaultIdentityValues::AVATAR_URL.to_string());
let badge_label =
if !certifier_info.name.is_empty() && certifier_info.name != "Unknown Certifier" {
format!("Certified by {}", certifier_info.name)
} else {
DefaultIdentityValues::BADGE_LABEL.to_string()
};
let badge_icon = if !certifier_info.icon_url.is_empty() {
certifier_info.icon_url.clone()
} else {
DefaultIdentityValues::BADGE_ICON_URL.to_string()
};
(
name,
avatar,
badge_label,
badge_icon,
DefaultIdentityValues::BADGE_CLICK_URL.to_string(),
)
}
}
impl<W: WalletInterface + Clone> IdentityClient<W> {
pub fn new(wallet: W, config: IdentityClientConfig) -> Self {
let resolver = Arc::new(LookupResolver::new(LookupResolverConfig {
network_preset: config.network_preset,
..Default::default()
}));
let contacts_config = ContactsManagerConfig {
originator: config.originator.clone(),
..Default::default()
};
let contacts_manager = ContactsManager::new(wallet.clone(), contacts_config);
Self {
wallet,
config,
resolver,
contacts_manager,
}
}
pub fn with_resolver(
wallet: W,
config: IdentityClientConfig,
resolver: Arc<LookupResolver>,
) -> Self {
let contacts_config = ContactsManagerConfig {
originator: config.originator.clone(),
..Default::default()
};
let contacts_manager = ContactsManager::new(wallet.clone(), contacts_config);
Self {
wallet,
config,
resolver,
contacts_manager,
}
}
pub async fn get_identity_key(&self) -> Result<String> {
let originator = self.config.originator.as_deref().unwrap_or("");
let result = self
.wallet
.get_public_key(
crate::wallet::GetPublicKeyArgs {
identity_key: true,
protocol_id: None,
key_id: None,
counterparty: None,
for_self: None,
},
originator,
)
.await?;
Ok(result.public_key)
}
pub async fn publicly_reveal_attributes(
&self,
certificate: WalletCertificate,
fields_to_reveal: Vec<CertificateFieldNameUnder50Bytes>,
) -> Result<BroadcastResult> {
if certificate.fields.is_empty() {
return Err(Error::IdentityError(
"Public reveal failed: Certificate has no fields to reveal!".to_string(),
));
}
if fields_to_reveal.is_empty() {
return Err(Error::IdentityError(
"Public reveal failed: You must reveal at least one field!".to_string(),
));
}
if certificate.subject.is_empty() || certificate.certifier.is_empty() {
return Err(Error::IdentityError(
"Public reveal failed: Certificate must have subject and certifier!".to_string(),
));
}
let originator = self
.config
.originator
.as_deref()
.unwrap_or("identity-client");
let anyone_verifier = PrivateKey::from_bytes(&[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 1,
])?
.public_key()
.to_hex();
let prove_result = self
.wallet
.prove_certificate(
ProveCertificateArgs {
certificate: certificate.clone(),
fields_to_reveal: fields_to_reveal.clone(),
verifier: anyone_verifier.clone(),
privileged: None,
privileged_reason: None,
},
originator,
)
.await?;
let cert_with_keyring = serde_json::json!({
"type": certificate.certificate_type,
"serialNumber": certificate.serial_number,
"subject": certificate.subject,
"certifier": certificate.certifier,
"revocationOutpoint": certificate.revocation_outpoint,
"fields": certificate.fields,
"signature": certificate.signature,
"keyring": prove_result.keyring_for_verifier,
});
let cert_json = serde_json::to_vec(&cert_with_keyring)
.map_err(|e| Error::IdentityError(format!("Failed to serialize certificate: {}", e)))?;
let protocol = crate::wallet::Protocol::from_tuple((
self.config.protocol_id.0,
&self.config.protocol_id.1,
))
.ok_or_else(|| {
Error::IdentityError(format!(
"Invalid protocol ID: ({}, {})",
self.config.protocol_id.0, self.config.protocol_id.1
))
})?;
let wallet_pubkey = self
.wallet
.get_public_key(
crate::wallet::GetPublicKeyArgs {
identity_key: false,
protocol_id: Some(protocol),
key_id: Some(self.config.key_id.clone()),
counterparty: Some(crate::wallet::Counterparty::Anyone),
for_self: Some(false),
},
originator,
)
.await?;
let locking_pubkey = PublicKey::from_hex(&wallet_pubkey.public_key)?;
let pushdrop = PushDrop::new(locking_pubkey, vec![cert_json]);
let locking_script = pushdrop.lock();
let create_result = self
.wallet
.create_action(
CreateActionArgs {
description: "Create a new Identity Token".to_string(),
input_beef: None,
inputs: None,
outputs: Some(vec![CreateActionOutput {
locking_script: locking_script.to_binary(),
satoshis: self.config.token_amount,
output_description: "Identity Token".to_string(),
basket: None,
custom_instructions: None,
tags: Some(vec!["identity".to_string()]),
}]),
lock_time: None,
version: None,
labels: Some(vec!["identity".to_string(), "revelation".to_string()]),
options: Some(CreateActionOptions {
sign_and_process: Some(true),
accept_delayed_broadcast: Some(false),
trust_self: None,
known_txids: None,
return_txid_only: None,
no_send: None,
no_send_change: None,
send_with: None,
randomize_outputs: Some(false),
}),
},
originator,
)
.await?;
let tx_bytes = match create_result.tx {
Some(tx) => tx,
None => {
return Ok(BroadcastResult::Failure(BroadcastFailure {
code: "CREATE_ACTION_FAILED".to_string(),
description: "Public reveal failed: createAction did not return a transaction"
.to_string(),
}));
}
};
let tx = Transaction::from_atomic_beef(&tx_bytes).map_err(|e| {
Error::IdentityError(format!("Failed to parse transaction from BEEF: {}", e))
})?;
let broadcaster = TopicBroadcaster::new(
vec!["tm_identity".to_string()],
TopicBroadcasterConfig {
network_preset: self.config.network_preset,
..Default::default()
},
)?;
let broadcast_result = broadcaster.broadcast(&tx).await;
match broadcast_result {
Ok(response) => Ok(BroadcastResult::Success(super::types::BroadcastSuccess {
txid: response.txid,
message: Some(response.message),
})),
Err(failure) => Ok(BroadcastResult::Failure(BroadcastFailure {
code: failure.code,
description: failure.description,
})),
}
}
pub async fn publicly_reveal_attributes_simple(
&self,
certificate: WalletCertificate,
fields_to_reveal: Vec<CertificateFieldNameUnder50Bytes>,
) -> Result<String> {
let result = self
.publicly_reveal_attributes(certificate, fields_to_reveal)
.await?;
match result {
BroadcastResult::Success(success) => Ok(success.txid),
BroadcastResult::Failure(failure) => Err(Error::IdentityError(format!(
"Broadcast failed: {}",
failure.description
))),
}
}
pub async fn revoke_certificate_revelation(&self, serial_number: &str) -> Result<()> {
let originator = self
.config
.originator
.as_deref()
.unwrap_or("identity-client");
let question = LookupQuestion::new(
"ls_identity",
serde_json::json!({
"serialNumber": serial_number
}),
);
let answer = self.resolver.query(&question, None).await?;
let outputs = match answer {
LookupAnswer::OutputList { outputs } => outputs,
_ => {
return Err(Error::IdentityError(
"Failed to find revelation output".to_string(),
))
}
};
if outputs.is_empty() {
return Err(Error::IdentityError(
"No revelation found for this certificate".to_string(),
));
}
let output = &outputs[0];
let beef = Beef::from_binary(&output.beef).map_err(|e| {
Error::IdentityError(format!("Failed to parse BEEF for revelation: {}", e))
})?;
let tx = beef
.txs
.first()
.ok_or_else(|| Error::IdentityError("BEEF contains no transactions".to_string()))?;
let raw_tx = tx.raw_tx().ok_or_else(|| {
Error::IdentityError("Failed to get raw transaction from BEEF".to_string())
})?;
let txid = tx.txid();
let parsed_tx = parse_transaction(raw_tx)
.map_err(|e| Error::IdentityError(format!("Failed to parse transaction: {}", e)))?;
let output_index = self.config.output_index as usize;
if output_index >= parsed_tx.outputs.len() {
return Err(Error::IdentityError(format!(
"Output index {} out of range (tx has {} outputs)",
output_index,
parsed_tx.outputs.len()
)));
}
let locking_script = &parsed_tx.outputs[output_index].script;
let source_satoshis = parsed_tx.outputs[output_index].satoshis;
let outpoint_str = format!("{}.{}", txid, output_index);
let outpoint = Outpoint::from_string(&outpoint_str)?;
let create_result = self
.wallet
.create_action(
CreateActionArgs {
description: "Spend certificate revelation token".to_string(),
input_beef: Some(output.beef.clone()),
inputs: Some(vec![CreateActionInput {
outpoint,
input_description: "Revelation token".to_string(),
unlocking_script: None,
unlocking_script_length: Some(74), sequence_number: None,
}]),
outputs: None, lock_time: None,
version: None,
labels: Some(vec!["identity".to_string(), "revocation".to_string()]),
options: Some(CreateActionOptions {
sign_and_process: Some(false),
accept_delayed_broadcast: Some(false),
trust_self: None,
known_txids: None,
return_txid_only: None,
no_send: Some(true),
no_send_change: None,
send_with: None,
randomize_outputs: Some(false),
}),
},
originator,
)
.await?;
let signable_tx = create_result.signable_transaction.ok_or_else(|| {
Error::IdentityError("createAction did not return a signable transaction".to_string())
})?;
let partial_tx = Transaction::from_beef(&signable_tx.tx, None).map_err(|e| {
Error::IdentityError(format!("Failed to parse signable transaction: {}", e))
})?;
let partial_raw = partial_tx.to_binary();
let partial_parsed = parse_transaction(&partial_raw).map_err(|e| {
Error::IdentityError(format!("Failed to parse partial transaction: {}", e))
})?;
let scope = SIGHASH_ALL | SIGHASH_FORKID;
let sighash_params = SighashParams {
version: partial_parsed.version,
inputs: &partial_parsed.inputs,
outputs: &partial_parsed.outputs,
locktime: partial_parsed.locktime,
input_index: 0, subscript: locking_script,
satoshis: source_satoshis,
scope,
};
let preimage = crate::primitives::bsv::sighash::build_sighash_preimage(&sighash_params);
let preimage_hash = sha256(&preimage);
let protocol =
Protocol::from_tuple((self.config.protocol_id.0, &self.config.protocol_id.1))
.ok_or_else(|| {
Error::IdentityError(format!(
"Invalid protocol ID: ({}, {})",
self.config.protocol_id.0, self.config.protocol_id.1
))
})?;
let sig_result = self
.wallet
.create_signature(
CreateSignatureArgs {
data: None,
hash_to_directly_sign: Some(preimage_hash),
protocol_id: protocol,
key_id: self.config.key_id.clone(),
counterparty: Some(Counterparty::Anyone),
},
originator,
)
.await?;
let signature = Signature::from_der(&sig_result.signature)?;
let tx_sig = TransactionSignature::new(signature, scope);
let checksig_format = tx_sig.to_checksig_format();
let mut unlocking_script = Vec::new();
unlocking_script.push(checksig_format.len() as u8);
unlocking_script.extend_from_slice(&checksig_format);
let mut spends = HashMap::new();
spends.insert(
0u32,
SignActionSpend {
unlocking_script,
sequence_number: None,
},
);
let sign_result = self
.wallet
.sign_action(
SignActionArgs {
spends,
reference: to_hex(&signable_tx.reference),
options: Some(SignActionOptions {
accept_delayed_broadcast: Some(false),
return_txid_only: None,
no_send: Some(true),
send_with: None,
}),
},
originator,
)
.await?;
let signed_tx_bytes = sign_result.tx.ok_or_else(|| {
Error::IdentityError("signAction did not return a transaction".to_string())
})?;
let signed_tx = Transaction::from_atomic_beef(&signed_tx_bytes).map_err(|e| {
Error::IdentityError(format!("Failed to parse signed transaction: {}", e))
})?;
let broadcaster = TopicBroadcaster::new(
vec!["tm_identity".to_string()],
TopicBroadcasterConfig {
network_preset: self.config.network_preset,
..Default::default()
},
)?;
broadcaster.broadcast(&signed_tx).await.map_err(|e| {
Error::IdentityError(format!("Failed to broadcast revocation: {}", e.description))
})?;
Ok(())
}
pub async fn resolve_by_identity_key(
&self,
identity_key: &str,
override_with_contacts: bool,
) -> Result<Option<DisplayableIdentity>> {
if override_with_contacts {
if let Ok(Some(contact)) = self.contacts_manager.get_contact(identity_key).await {
return Ok(Some(contact.to_displayable_identity()));
}
}
let question = LookupQuestion::new(
"ls_identity",
serde_json::json!({
"identityKey": identity_key,
"certifiers": [DEFAULT_SOCIALCERT_CERTIFIER]
}),
);
let answer = self.resolver.query(&question, None).await?;
match answer {
LookupAnswer::OutputList { outputs } => {
if outputs.is_empty() {
return Ok(None);
}
if let Some(cert) = self.parse_output_to_certificate(&outputs[0]).await? {
let identity = Self::parse_identity(&cert);
return Ok(Some(identity));
}
Ok(None)
}
_ => Ok(None),
}
}
pub async fn resolve_by_attributes(
&self,
attributes: HashMap<String, String>,
override_with_contacts: bool,
) -> Result<Vec<DisplayableIdentity>> {
let contacts_map: HashMap<String, Contact> = if override_with_contacts {
self.contacts_manager
.list_contacts()
.await
.unwrap_or_default()
.into_iter()
.map(|c| (c.identity_key.clone(), c))
.collect()
} else {
HashMap::new()
};
let query_attributes: serde_json::Value = if attributes.len() == 1 {
let value = attributes.values().next().unwrap();
serde_json::json!({ "any": value })
} else {
serde_json::to_value(&attributes).unwrap_or_default()
};
let question = LookupQuestion::new(
"ls_identity",
serde_json::json!({
"attributes": query_attributes,
"certifiers": [DEFAULT_SOCIALCERT_CERTIFIER]
}),
);
let answer = self.resolver.query(&question, None).await?;
match answer {
LookupAnswer::OutputList { outputs } => {
let mut identities = Vec::new();
for output in &outputs {
if let Some(cert) = self.parse_output_to_certificate(output).await? {
let subject = cert.subject_hex();
if let Some(contact) = contacts_map.get(&subject) {
identities.push(contact.to_displayable_identity());
} else {
identities.push(Self::parse_identity(&cert));
}
}
}
Ok(identities)
}
_ => Ok(Vec::new()),
}
}
pub async fn discover_certificates(
&self,
identity_key: &str,
) -> Result<Vec<VerifiableCertificate>> {
let question = LookupQuestion::new(
"ls_identity",
serde_json::json!({
"identityKey": identity_key,
"certificatesOnly": true
}),
);
let answer = self.resolver.query(&question, None).await?;
match answer {
LookupAnswer::OutputList { outputs } => {
let mut certificates = Vec::new();
for output in &outputs {
if let Some(cert) = self.parse_output_to_verifiable_certificate(output).await? {
certificates.push(cert);
}
}
Ok(certificates)
}
_ => Ok(Vec::new()),
}
}
pub async fn discover_certificates_by_type(
&self,
identity_key: &str,
cert_type: &str,
) -> Result<Vec<VerifiableCertificate>> {
let question = LookupQuestion::new(
"ls_identity",
serde_json::json!({
"identityKey": identity_key,
"certificateType": cert_type
}),
);
let answer = self.resolver.query(&question, None).await?;
match answer {
LookupAnswer::OutputList { outputs } => {
let mut certificates = Vec::new();
for output in &outputs {
if let Some(cert) = self.parse_output_to_verifiable_certificate(output).await? {
certificates.push(cert);
}
}
Ok(certificates)
}
_ => Ok(Vec::new()),
}
}
pub async fn query(&self, query: IdentityQuery) -> Result<Vec<IdentityResolutionResult>> {
let mut lookup_query = serde_json::Map::new();
if let Some(ref key) = query.identity_key {
lookup_query.insert("identityKey".to_string(), serde_json::json!(key));
}
if let Some(ref attrs) = query.attributes {
lookup_query.insert(
"attributes".to_string(),
serde_json::to_value(attrs).map_err(|e| Error::IdentityError(e.to_string()))?,
);
}
if let Some(ref cert_type) = query.certificate_type {
lookup_query.insert("certificateType".to_string(), serde_json::json!(cert_type));
}
if let Some(ref certifier) = query.certifier {
lookup_query.insert("certifiers".to_string(), serde_json::json!([certifier]));
} else {
lookup_query.insert(
"certifiers".to_string(),
serde_json::json!([DEFAULT_SOCIALCERT_CERTIFIER]),
);
}
if let Some(limit) = query.limit {
lookup_query.insert("limit".to_string(), serde_json::json!(limit));
}
if let Some(offset) = query.offset {
lookup_query.insert("offset".to_string(), serde_json::json!(offset));
}
let question = LookupQuestion::new("ls_identity", serde_json::Value::Object(lookup_query));
let answer = self.resolver.query(&question, None).await?;
match answer {
LookupAnswer::OutputList { outputs } => {
let mut results = Vec::new();
for output in &outputs {
if let Some(cert) = self.parse_output_to_certificate(output).await? {
let identity = Self::parse_identity(&cert);
results.push(IdentityResolutionResult {
identity,
certificates: vec![cert.certificate.clone()],
});
}
}
Ok(results)
}
_ => Ok(Vec::new()),
}
}
pub async fn get_contacts(&self, force_refresh: bool) -> Result<Vec<Contact>> {
self.contacts_manager
.list_contacts_with_refresh(force_refresh)
.await
}
pub async fn get_contact(&self, identity_key: &str) -> Result<Option<Contact>> {
self.contacts_manager.get_contact(identity_key).await
}
pub async fn save_contact(
&self,
identity: DisplayableIdentity,
metadata: Option<serde_json::Value>,
) -> Result<()> {
let mut contact = Contact::from_identity(identity);
contact.metadata = metadata;
self.contacts_manager.add_contact(contact).await
}
pub async fn remove_contact(&self, identity_key: &str) -> Result<()> {
self.contacts_manager.remove_contact(identity_key).await
}
async fn parse_output_to_certificate(
&self,
output: &OutputListItem,
) -> Result<Option<IdentityCertificate>> {
let beef = match Beef::from_binary(&output.beef) {
Ok(b) => b,
Err(_) => return Ok(None), };
let tx = match beef.txs.first() {
Some(beef_tx) => match beef_tx.tx() {
Some(t) => t,
None => return Ok(None), },
None => return Ok(None), };
let tx_output = match tx.outputs.get(output.output_index as usize) {
Some(o) => o,
None => return Ok(None), };
let pushdrop = match PushDrop::decode(&tx_output.locking_script) {
Ok(pd) => pd,
Err(_) => return Ok(None), };
let cert_json_bytes = match pushdrop.fields.first() {
Some(f) => f,
None => return Ok(None), };
let cert_json_str = match String::from_utf8(cert_json_bytes.clone()) {
Ok(s) => s,
Err(_) => return Ok(None), };
let cert_data: serde_json::Value = match serde_json::from_str(&cert_json_str) {
Ok(v) => v,
Err(_) => return Ok(None), };
let cert_type_b64 = cert_data["type"]
.as_str()
.ok_or_else(|| Error::IdentityError("Missing certificate type".to_string()))?;
let serial_number_b64 = cert_data["serialNumber"]
.as_str()
.ok_or_else(|| Error::IdentityError("Missing serial number".to_string()))?;
let subject_hex = cert_data["subject"]
.as_str()
.ok_or_else(|| Error::IdentityError("Missing subject".to_string()))?;
let certifier_hex = cert_data["certifier"]
.as_str()
.ok_or_else(|| Error::IdentityError("Missing certifier".to_string()))?;
let cert_type_bytes = from_base64(cert_type_b64)?;
let serial_number_bytes = from_base64(serial_number_b64)?;
let cert_type: [u8; 32] = cert_type_bytes
.try_into()
.map_err(|_| Error::IdentityError("Invalid certificate type length".to_string()))?;
let serial_number: [u8; 32] = serial_number_bytes
.try_into()
.map_err(|_| Error::IdentityError("Invalid serial number length".to_string()))?;
let subject = PublicKey::from_hex(subject_hex)?;
let certifier = PublicKey::from_hex(certifier_hex)?;
let mut certificate =
Certificate::new(cert_type, serial_number, subject.clone(), certifier);
if let Some(outpoint_str) = cert_data["revocationOutpoint"].as_str() {
if !outpoint_str.is_empty() {
certificate.revocation_outpoint =
Some(crate::wallet::types::Outpoint::from_string(outpoint_str)?);
}
}
if let Some(fields_obj) = cert_data["fields"].as_object() {
for (field_name, field_value) in fields_obj {
if let Some(value_str) = field_value.as_str() {
let encrypted_value = from_base64(value_str)?;
certificate.set_field(field_name.clone(), encrypted_value);
}
}
}
if let Some(sig_str) = cert_data["signature"].as_str() {
certificate.signature = Some(from_base64(sig_str)?);
}
let mut keyring: HashMap<String, Vec<u8>> = HashMap::new();
if let Some(keyring_obj) = cert_data["keyring"].as_object() {
for (field_name, key_value) in keyring_obj {
if let Some(key_str) = key_value.as_str() {
let key_bytes = from_base64(key_str)?;
keyring.insert(field_name.clone(), key_bytes);
}
}
}
let mut verifiable_cert = VerifiableCertificate::new(certificate, keyring.clone());
match verifiable_cert.verify() {
Ok(true) => {}
Ok(false) | Err(_) => return Ok(None), }
let anyone_wallet = ProtoWallet::anyone();
let originator = self
.config
.originator
.as_deref()
.unwrap_or("identity-client");
let decrypted_fields: HashMap<String, String> = verifiable_cert
.decrypt_fields(&anyone_wallet, &subject, originator)
.await
.unwrap_or_default();
let certifier_info = CertifierInfo {
name: "SocialCert".to_string(),
icon_url: "https://socialcert.net/favicon.ico".to_string(),
description: "Social identity verification".to_string(),
trust: 5,
};
Ok(Some(IdentityCertificate {
certificate: verifiable_cert,
certifier_info,
publicly_revealed_keyring: keyring,
decrypted_fields,
}))
}
async fn parse_output_to_verifiable_certificate(
&self,
output: &OutputListItem,
) -> Result<Option<VerifiableCertificate>> {
let beef = match Beef::from_binary(&output.beef) {
Ok(b) => b,
Err(_) => return Ok(None), };
let tx = match beef.txs.first() {
Some(beef_tx) => match beef_tx.tx() {
Some(t) => t,
None => return Ok(None), },
None => return Ok(None), };
let tx_output = match tx.outputs.get(output.output_index as usize) {
Some(o) => o,
None => return Ok(None), };
let pushdrop = match PushDrop::decode(&tx_output.locking_script) {
Ok(pd) => pd,
Err(_) => return Ok(None), };
let cert_json_bytes = match pushdrop.fields.first() {
Some(f) => f,
None => return Ok(None), };
let cert_json_str = match String::from_utf8(cert_json_bytes.clone()) {
Ok(s) => s,
Err(_) => return Ok(None), };
let cert_data: serde_json::Value = match serde_json::from_str(&cert_json_str) {
Ok(v) => v,
Err(_) => return Ok(None), };
let cert_type_b64 = match cert_data["type"].as_str() {
Some(s) => s,
None => return Ok(None), };
let serial_number_b64 = match cert_data["serialNumber"].as_str() {
Some(s) => s,
None => return Ok(None), };
let subject_hex = match cert_data["subject"].as_str() {
Some(s) => s,
None => return Ok(None), };
let certifier_hex = match cert_data["certifier"].as_str() {
Some(s) => s,
None => return Ok(None), };
let cert_type_bytes = match from_base64(cert_type_b64) {
Ok(b) => b,
Err(_) => return Ok(None), };
let serial_number_bytes = match from_base64(serial_number_b64) {
Ok(b) => b,
Err(_) => return Ok(None), };
let cert_type: [u8; 32] = match cert_type_bytes.try_into() {
Ok(a) => a,
Err(_) => return Ok(None), };
let serial_number: [u8; 32] = match serial_number_bytes.try_into() {
Ok(a) => a,
Err(_) => return Ok(None), };
let subject = match PublicKey::from_hex(subject_hex) {
Ok(pk) => pk,
Err(_) => return Ok(None), };
let certifier = match PublicKey::from_hex(certifier_hex) {
Ok(pk) => pk,
Err(_) => return Ok(None), };
let mut certificate = Certificate::new(cert_type, serial_number, subject, certifier);
if let Some(outpoint_str) = cert_data["revocationOutpoint"].as_str() {
if !outpoint_str.is_empty() {
certificate.revocation_outpoint =
crate::wallet::types::Outpoint::from_string(outpoint_str).ok();
}
}
if let Some(fields_obj) = cert_data["fields"].as_object() {
for (field_name, field_value) in fields_obj {
if let Some(value_str) = field_value.as_str() {
if let Ok(encrypted_value) = from_base64(value_str) {
certificate.set_field(field_name.clone(), encrypted_value);
}
}
}
}
if let Some(sig_str) = cert_data["signature"].as_str() {
if let Ok(sig_bytes) = from_base64(sig_str) {
certificate.signature = Some(sig_bytes);
}
}
let mut keyring: HashMap<String, Vec<u8>> = HashMap::new();
if let Some(keyring_obj) = cert_data["keyring"].as_object() {
for (field_name, key_value) in keyring_obj {
if let Some(key_str) = key_value.as_str() {
if let Ok(key_bytes) = from_base64(key_str) {
keyring.insert(field_name.clone(), key_bytes);
}
}
}
}
let verifiable_cert = VerifiableCertificate::new(certificate, keyring);
match verifiable_cert.verify() {
Ok(true) => Ok(Some(verifiable_cert)),
Ok(false) | Err(_) => Ok(None), }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_identity_x_cert() {
let mut decrypted = HashMap::new();
decrypted.insert("userName".to_string(), "alice_x".to_string());
decrypted.insert(
"profilePhoto".to_string(),
"https://example.com/avatar.png".to_string(),
);
let certifier_info = CertifierInfo {
name: "SocialCert".to_string(),
icon_url: "https://socialcert.net/icon.png".to_string(),
description: "Social verification".to_string(),
trust: 5,
};
let (name, avatar, badge, _, _) =
IdentityClient::<crate::wallet::ProtoWallet>::try_parse_generic_identity(
&decrypted,
&certifier_info,
);
assert!(name == "alice_x" || name.contains("alice"));
assert_eq!(avatar, "https://example.com/avatar.png");
assert!(badge.contains("SocialCert"));
}
#[test]
fn test_parse_identity_generic_fallback() {
let mut decrypted = HashMap::new();
decrypted.insert("firstName".to_string(), "Alice".to_string());
decrypted.insert("lastName".to_string(), "Smith".to_string());
let certifier_info = CertifierInfo {
name: "TestCert".to_string(),
icon_url: "https://test.com/icon.png".to_string(),
description: "Test".to_string(),
trust: 3,
};
let (name, avatar, badge, icon, _) =
IdentityClient::<crate::wallet::ProtoWallet>::try_parse_generic_identity(
&decrypted,
&certifier_info,
);
assert_eq!(name, "Alice Smith");
assert_eq!(avatar, DefaultIdentityValues::AVATAR_URL);
assert!(badge.contains("TestCert"));
assert_eq!(icon, "https://test.com/icon.png");
}
#[test]
fn test_parse_identity_no_fields() {
let decrypted = HashMap::new();
let certifier_info = CertifierInfo::default();
let (name, avatar, badge, icon, click_url) =
IdentityClient::<crate::wallet::ProtoWallet>::try_parse_generic_identity(
&decrypted,
&certifier_info,
);
assert_eq!(name, DefaultIdentityValues::NAME);
assert_eq!(avatar, DefaultIdentityValues::AVATAR_URL);
assert_eq!(badge, DefaultIdentityValues::BADGE_LABEL);
assert_eq!(icon, DefaultIdentityValues::BADGE_ICON_URL);
assert_eq!(click_url, DefaultIdentityValues::BADGE_CLICK_URL);
}
#[test]
fn test_identity_client_config() {
let config = IdentityClientConfig::with_originator("test.com")
.with_network(crate::overlay::NetworkPreset::Testnet)
.with_token_amount(10);
assert_eq!(config.originator, Some("test.com".to_string()));
assert_eq!(
config.network_preset,
crate::overlay::NetworkPreset::Testnet
);
assert_eq!(config.token_amount, 10);
}
#[tokio::test]
async fn test_publicly_reveal_attributes_validation() {
use crate::primitives::PrivateKey;
use crate::wallet::ProtoWallet;
let wallet = ProtoWallet::new(Some(PrivateKey::random()));
let client = IdentityClient::new(wallet, IdentityClientConfig::default());
let empty_cert = WalletCertificate {
certificate_type: "test".to_string(),
subject: "".to_string(),
serial_number: "123".to_string(),
certifier: "".to_string(),
revocation_outpoint: "".to_string(),
signature: "".to_string(),
fields: HashMap::new(),
};
let result = client
.publicly_reveal_attributes(empty_cert, vec!["field1".to_string()])
.await;
assert!(matches!(result, Err(Error::IdentityError(_))));
let mut fields = HashMap::new();
fields.insert("name".to_string(), "Alice".to_string());
let cert_with_fields = WalletCertificate {
certificate_type: "test".to_string(),
subject: "subject".to_string(),
serial_number: "123".to_string(),
certifier: "certifier".to_string(),
revocation_outpoint: "".to_string(),
signature: "".to_string(),
fields,
};
let result = client
.publicly_reveal_attributes(cert_with_fields, vec![])
.await;
assert!(matches!(result, Err(Error::IdentityError(_))));
}
#[test]
fn test_broadcast_result() {
let success = BroadcastResult::Success(BroadcastSuccess {
txid: "abc123".to_string(),
message: Some("Success".to_string()),
});
assert!(success.is_success());
assert_eq!(success.txid(), Some("abc123"));
let failure = BroadcastResult::Failure(BroadcastFailure {
code: "ERROR".to_string(),
description: "Something went wrong".to_string(),
});
assert!(!failure.is_success());
assert_eq!(failure.txid(), None);
}
#[test]
fn test_parse_identity_email_cert() {
let mut decrypted = HashMap::new();
decrypted.insert("email".to_string(), "alice@example.com".to_string());
let certifier_info = CertifierInfo {
name: "Email Certifier".to_string(),
icon_url: "https://email-certifier.com/icon.png".to_string(),
description: "Email verification".to_string(),
trust: 5,
};
let (name, avatar, badge, icon, _) =
IdentityClient::<crate::wallet::ProtoWallet>::try_parse_generic_identity(
&decrypted,
&certifier_info,
);
assert_eq!(name, "alice@example.com");
assert_eq!(avatar, DefaultIdentityValues::AVATAR_URL);
assert!(badge.contains("Email Certifier"));
assert_eq!(icon, "https://email-certifier.com/icon.png");
}
#[test]
fn test_parse_identity_phone_cert() {
let mut decrypted = HashMap::new();
decrypted.insert("phoneNumber".to_string(), "+1-555-123-4567".to_string());
let certifier_info = CertifierInfo {
name: "Phone Certifier".to_string(),
icon_url: "https://phone-certifier.com/icon.png".to_string(),
description: "Phone verification".to_string(),
trust: 5,
};
let (name, _, badge, _, _) =
IdentityClient::<crate::wallet::ProtoWallet>::try_parse_generic_identity(
&decrypted,
&certifier_info,
);
assert!(name.contains("555") || name == DefaultIdentityValues::NAME);
assert!(badge.contains("Phone Certifier"));
}
#[test]
fn test_parse_identity_identicert() {
let mut decrypted = HashMap::new();
decrypted.insert("firstName".to_string(), "John".to_string());
decrypted.insert("lastName".to_string(), "Doe".to_string());
decrypted.insert(
"profilePhoto".to_string(),
"https://example.com/john.png".to_string(),
);
let certifier_info = CertifierInfo {
name: "IdentiCert".to_string(),
icon_url: "https://identicert.com/icon.png".to_string(),
description: "Government ID verification".to_string(),
trust: 8,
};
let (name, avatar, badge, _, _) =
IdentityClient::<crate::wallet::ProtoWallet>::try_parse_generic_identity(
&decrypted,
&certifier_info,
);
assert_eq!(name, "John Doe");
assert_eq!(avatar, "https://example.com/john.png");
assert!(badge.contains("IdentiCert"));
}
#[test]
fn test_parse_identity_registrant_cert() {
let mut decrypted = HashMap::new();
decrypted.insert("name".to_string(), "Example Organization".to_string());
decrypted.insert(
"icon".to_string(),
"https://example.org/icon.png".to_string(),
);
let certifier_info = CertifierInfo {
name: "Registrant Certifier".to_string(),
icon_url: "https://registrant.com/icon.png".to_string(),
description: "Organization registration".to_string(),
trust: 6,
};
let (name, avatar, _, _, _) =
IdentityClient::<crate::wallet::ProtoWallet>::try_parse_generic_identity(
&decrypted,
&certifier_info,
);
assert_eq!(name, "Example Organization");
assert_eq!(avatar, "https://example.org/icon.png");
}
#[test]
fn test_known_certificate_type_ids() {
use crate::identity::KnownCertificateType;
assert_eq!(
KnownCertificateType::XCert.type_id(),
"vdDWvftf1H+5+ZprUw123kjHlywH+v20aPQTuXgMpNc="
);
assert_eq!(
KnownCertificateType::EmailCert.type_id(),
"exOl3KM0dIJ04EW5pZgbZmPag6MdJXd3/a1enmUU/BA="
);
assert_eq!(
KnownCertificateType::DiscordCert.type_id(),
"2TgqRC35B1zehGmB21xveZNc7i5iqHc0uxMb+1NMPW4="
);
assert_eq!(
KnownCertificateType::PhoneCert.type_id(),
"mffUklUzxbHr65xLohn0hRL0Tq2GjW1GYF/OPfzqJ6A="
);
assert_eq!(
KnownCertificateType::IdentiCert.type_id(),
"z40BOInXkI8m7f/wBrv4MJ09bZfzZbTj2fJqCtONqCY="
);
assert_eq!(
KnownCertificateType::Registrant.type_id(),
"YoPsbfR6YQczjzPdHCoGC7nJsOdPQR50+SYqcWpJ0y0="
);
assert_eq!(
KnownCertificateType::Anyone.type_id(),
"mfkOMfLDQmrr3SBxBQ5WeE+6Hy3VJRFq6w4A5Ljtlis="
);
assert_eq!(
KnownCertificateType::SelfCert.type_id(),
"Hkge6X5JRxt1cWXtHLCrSTg6dCVTxjQJJ48iOYd7n3g="
);
assert_eq!(
KnownCertificateType::CoolCert.type_id(),
"AGfk/WrT1eBDXpz3mcw386Zww2HmqcIn3uY6x4Af1eo="
);
}
#[test]
fn test_known_certificate_type_roundtrip() {
use crate::identity::KnownCertificateType;
for cert_type in KnownCertificateType::all() {
let type_id = cert_type.type_id();
let parsed = KnownCertificateType::from_type_id(type_id);
assert_eq!(
parsed,
Some(*cert_type),
"Roundtrip failed for {:?}",
cert_type
);
}
}
#[test]
fn test_known_certificate_type_names() {
use crate::identity::KnownCertificateType;
assert_eq!(KnownCertificateType::XCert.name(), "XCert");
assert_eq!(KnownCertificateType::EmailCert.name(), "EmailCert");
assert_eq!(KnownCertificateType::DiscordCert.name(), "DiscordCert");
assert_eq!(KnownCertificateType::PhoneCert.name(), "PhoneCert");
assert_eq!(KnownCertificateType::IdentiCert.name(), "IdentiCert");
assert_eq!(KnownCertificateType::Registrant.name(), "Registrant");
assert_eq!(KnownCertificateType::Anyone.name(), "Anyone");
assert_eq!(KnownCertificateType::SelfCert.name(), "Self"); assert_eq!(KnownCertificateType::CoolCert.name(), "CoolCert");
}
#[test]
fn test_unknown_certificate_type_returns_none() {
use crate::identity::KnownCertificateType;
assert_eq!(KnownCertificateType::from_type_id("unknown-type-id"), None);
}
#[test]
fn test_displayable_identity_from_key_abbreviation() {
let identity = DisplayableIdentity::from_key(
"02abc123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
);
assert_eq!(identity.abbreviated_key, "02abc1...abcd");
assert_eq!(
identity.identity_key,
"02abc123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd"
);
}
#[test]
fn test_displayable_identity_short_key_no_abbreviation() {
let identity = DisplayableIdentity::from_key("02abc");
assert_eq!(identity.abbreviated_key, "02abc");
assert_eq!(identity.identity_key, "02abc");
}
#[test]
fn test_displayable_identity_unknown() {
let identity = DisplayableIdentity::unknown();
assert_eq!(identity.name, DefaultIdentityValues::NAME);
assert_eq!(identity.avatar_url, DefaultIdentityValues::AVATAR_URL);
assert_eq!(identity.badge_label, DefaultIdentityValues::BADGE_LABEL);
assert_eq!(
identity.badge_icon_url,
DefaultIdentityValues::BADGE_ICON_URL
);
assert_eq!(
identity.badge_click_url,
DefaultIdentityValues::BADGE_CLICK_URL
);
assert!(identity.identity_key.is_empty());
}
#[test]
fn test_default_identity_values_match_sdks() {
assert_eq!(DefaultIdentityValues::NAME, "Unknown Identity");
assert_eq!(
DefaultIdentityValues::AVATAR_URL,
"XUUB8bbn9fEthk15Ge3zTQXypUShfC94vFjp65v7u5CQ8qkpxzst"
);
assert_eq!(
DefaultIdentityValues::BADGE_LABEL,
"Not verified by anyone you trust."
);
assert_eq!(
DefaultIdentityValues::BADGE_ICON_URL,
"XUUV39HVPkpmMzYNTx7rpKzJvXfeiVyQWg2vfSpjBAuhunTCA9uG"
);
assert_eq!(
DefaultIdentityValues::BADGE_CLICK_URL,
"https://projectbabbage.com/docs/unknown-identity"
);
}
#[test]
fn test_identity_query_builder() {
use crate::identity::IdentityQuery;
let query = IdentityQuery::by_identity_key("02abc123")
.with_certifier("02def456")
.with_limit(25)
.with_offset(10);
assert_eq!(query.identity_key, Some("02abc123".to_string()));
assert_eq!(query.certifier, Some("02def456".to_string()));
assert_eq!(query.limit, Some(25));
assert_eq!(query.offset, Some(10));
}
#[test]
fn test_identity_query_by_attribute() {
use crate::identity::IdentityQuery;
let query = IdentityQuery::by_attribute("email", "alice@example.com");
assert!(query.identity_key.is_none());
assert!(query.attributes.is_some());
let attrs = query.attributes.as_ref().unwrap();
assert_eq!(attrs.get("email"), Some(&"alice@example.com".to_string()));
}
#[test]
fn test_identity_query_by_multiple_attributes() {
use crate::identity::IdentityQuery;
let mut attrs = HashMap::new();
attrs.insert("firstName".to_string(), "John".to_string());
attrs.insert("lastName".to_string(), "Doe".to_string());
let query = IdentityQuery::by_attributes(attrs);
let attrs_map = query.attributes.as_ref().unwrap();
assert_eq!(attrs_map.get("firstName"), Some(&"John".to_string()));
assert_eq!(attrs_map.get("lastName"), Some(&"Doe".to_string()));
}
#[test]
fn test_certifier_info_default() {
let info = CertifierInfo::default();
assert_eq!(info.name, "Unknown Certifier");
assert_eq!(info.description, "No information available");
assert_eq!(info.trust, 0);
assert!(!info.icon_url.is_empty());
}
#[test]
fn test_broadcast_result_into_result_success() {
let success = BroadcastResult::Success(BroadcastSuccess {
txid: "txid123".to_string(),
message: Some("Broadcast successful".to_string()),
});
let result = success.into_result();
assert!(result.is_ok());
assert_eq!(result.unwrap().txid, "txid123");
}
#[test]
fn test_broadcast_result_into_result_failure() {
let failure = BroadcastResult::Failure(BroadcastFailure {
code: "FAILED".to_string(),
description: "Broadcast failed".to_string(),
});
let result = failure.into_result();
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, "FAILED");
}
#[test]
fn test_identity_client_config_creation() {
let config = IdentityClientConfig::default();
assert_eq!(config.protocol_id, (1, "identity".to_string()));
assert_eq!(config.key_id, "1");
assert_eq!(config.token_amount, 1);
assert_eq!(config.output_index, 0);
assert_eq!(
config.network_preset,
crate::overlay::NetworkPreset::Mainnet
);
assert!(config.originator.is_none());
}
#[test]
fn test_identity_client_config_builder_methods() {
let config = IdentityClientConfig::with_originator("test-app.example.com")
.with_network(crate::overlay::NetworkPreset::Testnet)
.with_token_amount(5);
assert_eq!(config.originator, Some("test-app.example.com".to_string()));
assert_eq!(
config.network_preset,
crate::overlay::NetworkPreset::Testnet
);
assert_eq!(config.token_amount, 5);
}
#[test]
fn test_identity_client_config_protocol_defaults() {
let config = IdentityClientConfig::default();
assert_eq!(config.protocol_id.0, 1); assert_eq!(config.protocol_id.1, "identity"); }
#[test]
fn test_wallet_certificate_validation() {
let mut fields = HashMap::new();
fields.insert("name".to_string(), "Alice".to_string());
fields.insert("email".to_string(), "alice@example.com".to_string());
let cert = WalletCertificate {
certificate_type: "test-type".to_string(),
subject: "02abc123".to_string(),
serial_number: "serial-123".to_string(),
certifier: "02def456".to_string(),
revocation_outpoint: "txid.0".to_string(),
signature: "sig".to_string(),
fields,
};
assert!(!cert.fields.is_empty());
assert_eq!(cert.fields.len(), 2);
assert_eq!(cert.fields.get("name"), Some(&"Alice".to_string()));
}
}