use k256::schnorr::SigningKey;
use thiserror::Error;
use crate::event::{sign_event, NostrEvent, UnsignedEvent};
const KIND_DELETION: u64 = 5;
#[derive(Debug, Error)]
pub enum DeletionError {
#[error("at least one target event ID is required")]
NoTargets,
#[error("invalid target event ID: {0}")]
InvalidTargetId(String),
#[error("invalid signing key: {0}")]
InvalidKey(String),
#[error("signing failed: {0}")]
SigningFailed(String),
}
pub fn create_deletion_event(
privkey: &[u8; 32],
target_ids: &[String],
reason: Option<&str>,
) -> Result<NostrEvent, DeletionError> {
if target_ids.is_empty() {
return Err(DeletionError::NoTargets);
}
for id in target_ids {
if id.len() != 64 || hex::decode(id).is_err() {
return Err(DeletionError::InvalidTargetId(id.clone()));
}
}
let signing_key =
SigningKey::from_bytes(privkey).map_err(|e| DeletionError::InvalidKey(e.to_string()))?;
let pubkey = hex::encode(signing_key.verifying_key().to_bytes());
let tags: Vec<Vec<String>> = target_ids
.iter()
.map(|id| vec!["e".to_string(), id.clone()])
.collect();
let now = now_secs();
let unsigned = UnsignedEvent {
pubkey,
created_at: now,
kind: KIND_DELETION,
tags,
content: reason.unwrap_or("").to_string(),
};
sign_event(unsigned, &signing_key).map_err(|e| DeletionError::SigningFailed(e.to_string()))
}
fn now_secs() -> u64 {
#[cfg(target_arch = "wasm32")]
{
(js_sys::Date::now() / 1000.0) as u64
}
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before UNIX epoch")
.as_secs()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::verify_event;
use k256::schnorr::SigningKey;
fn test_signing_key() -> (SigningKey, [u8; 32]) {
let secret = [0x01u8; 32];
let sk = SigningKey::from_bytes(&secret).unwrap();
(sk, secret)
}
#[test]
fn create_deletion_single_target() {
let (sk, privkey) = test_signing_key();
let pubkey = hex::encode(sk.verifying_key().to_bytes());
let target = "aa".repeat(32);
let event = create_deletion_event(&privkey, std::slice::from_ref(&target), None).unwrap();
assert_eq!(event.kind, 5);
assert_eq!(event.pubkey, pubkey);
assert_eq!(event.content, "");
assert_eq!(event.tags.len(), 1);
assert_eq!(event.tags[0], vec!["e", &target]);
assert!(verify_event(&event));
}
#[test]
fn create_deletion_multiple_targets_with_reason() {
let (_, privkey) = test_signing_key();
let targets = vec!["bb".repeat(32), "cc".repeat(32), "dd".repeat(32)];
let reason = "spam cleanup";
let event = create_deletion_event(&privkey, &targets, Some(reason)).unwrap();
assert_eq!(event.kind, 5);
assert_eq!(event.content, reason);
assert_eq!(event.tags.len(), 3);
for (i, target) in targets.iter().enumerate() {
assert_eq!(event.tags[i][0], "e");
assert_eq!(event.tags[i][1], *target);
}
assert!(verify_event(&event));
}
#[test]
fn create_deletion_no_targets_rejected() {
let (_, privkey) = test_signing_key();
let result = create_deletion_event(&privkey, &[], None);
assert!(matches!(result, Err(DeletionError::NoTargets)));
}
#[test]
fn create_deletion_invalid_target_id_rejected() {
let (_, privkey) = test_signing_key();
let result = create_deletion_event(&privkey, &["not-hex".to_string()], None);
assert!(matches!(result, Err(DeletionError::InvalidTargetId(_))));
}
#[test]
fn create_deletion_short_target_id_rejected() {
let (_, privkey) = test_signing_key();
let result = create_deletion_event(&privkey, &["aabb".to_string()], None);
assert!(matches!(result, Err(DeletionError::InvalidTargetId(_))));
}
#[test]
fn create_deletion_verifies_signature() {
let (_, privkey) = test_signing_key();
let target = "ee".repeat(32);
let event = create_deletion_event(&privkey, &[target], Some("test reason")).unwrap();
assert!(verify_event(&event));
}
#[test]
fn create_deletion_empty_reason_is_empty_content() {
let (_, privkey) = test_signing_key();
let target = "ff".repeat(32);
let event = create_deletion_event(&privkey, &[target], None).unwrap();
assert_eq!(event.content, "");
}
}