vector_core/community/v2/
dissolution.rs1use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, Timestamp, UnsignedEvent};
30
31use super::super::CommunityId;
32use super::control::CommunityIdentity;
33use super::derive::dissolved_group_key;
34use super::stream::{self, SealForm, StreamError};
35use super::{kind, vsk};
36
37const TAG_VSK: &str = "vsk";
38const TAG_EID: &str = "eid";
39
40#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct DissolvedTombstone {
44 pub owner: PublicKey,
45}
46
47#[derive(Debug)]
49pub enum DissolveError {
50 Stream(StreamError),
51 NotATombstone,
53}
54
55impl std::fmt::Display for DissolveError {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 match self {
58 DissolveError::Stream(e) => write!(f, "stream: {e}"),
59 DissolveError::NotATombstone => write!(f, "not a dissolution tombstone"),
60 }
61 }
62}
63
64impl std::error::Error for DissolveError {}
65
66impl From<StreamError> for DissolveError {
67 fn from(e: StreamError) -> Self {
68 DissolveError::Stream(e)
69 }
70}
71
72pub fn dissolved_tombstone_rumor(owner: PublicKey, community_id: &CommunityId, created_at_secs: u64) -> UnsignedEvent {
87 let tags = vec![
88 Tag::custom(TAG_VSK, [vsk::DISSOLVED.to_string()]),
89 Tag::custom(TAG_EID, [crate::simd::hex::bytes_to_hex_32(&community_id.0)]),
90 ];
91 stream::build_rumor_secs(kind::CONTROL, owner, "", tags, created_at_secs)
92}
93
94pub fn seal_dissolved(
98 rumor: &UnsignedEvent,
99 community_id: &CommunityId,
100 owner_keys: &Keys,
101 wrap_at: Timestamp,
102) -> Result<Event, DissolveError> {
103 let group = dissolved_group_key(community_id);
104 let seal = stream::build_seal(rumor, SealForm::Plaintext, &group, owner_keys)?;
105 let (wrap, _ephemeral) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, wrap_at)?;
106 Ok(wrap)
107}
108
109pub async fn seal_dissolved_signed<S: crate::signer::VectorSigner + ?Sized>(
113 signer: &S,
114 owner_pk: PublicKey,
115 rumor: &UnsignedEvent,
116 community_id: &CommunityId,
117 wrap_at: Timestamp,
118) -> Result<Event, DissolveError> {
119 let group = dissolved_group_key(community_id);
120 let (wrap, _ephemeral) = stream::seal_and_wrap_signed(signer, owner_pk, rumor, SealForm::Plaintext, &group, stream::KIND_WRAP, wrap_at, &[]).await?;
121 Ok(wrap)
122}
123
124pub fn open_dissolved(wrap: &Event, community_id: &CommunityId) -> Result<DissolvedTombstone, DissolveError> {
128 let group = dissolved_group_key(community_id);
129 let opened = stream::open_wrap(wrap, &group)?;
130 if !is_tombstone_rumor(&opened.rumor, community_id) {
134 return Err(DissolveError::NotATombstone);
135 }
136 Ok(DissolvedTombstone { owner: opened.author })
137}
138
139pub fn verify_dissolved(wrap: &Event, identity: &CommunityIdentity) -> bool {
144 if !identity.verify() {
145 return false;
146 }
147 let Ok(owner) = identity.owner() else {
148 return false;
149 };
150 match open_dissolved(wrap, &identity.community_id) {
151 Ok(tombstone) => tombstone.owner == owner,
152 Err(_) => false,
153 }
154}
155
156fn is_tombstone_rumor(rumor: &UnsignedEvent, community_id: &CommunityId) -> bool {
162 rumor.kind.as_u16() == kind::CONTROL
163 && first_tag(rumor, TAG_VSK).as_deref() == Some(vsk::DISSOLVED)
164 && first_tag(rumor, TAG_EID).as_deref() == Some(crate::simd::hex::bytes_to_hex_32(&community_id.0).as_str())
165}
166
167fn first_tag(rumor: &UnsignedEvent, name: &str) -> Option<String> {
168 rumor.tags.iter().find_map(|t| {
169 let s = t.as_slice();
170 (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
171 })
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn identity_and_owner() -> (CommunityIdentity, Keys) {
179 let owner = Keys::generate();
180 let identity = CommunityIdentity::mint(&owner.public_key());
181 assert!(identity.verify());
182 (identity, owner)
183 }
184
185 fn tombstone_for(identity: &CommunityIdentity, owner: &Keys) -> Event {
187 let rumor = dissolved_tombstone_rumor(owner.public_key(), &identity.community_id, 1_725_000_000);
188 seal_dissolved(&rumor, &identity.community_id, owner, Timestamp::from_secs(1_725_000_000)).unwrap()
189 }
190
191 #[test]
192 fn owner_tombstone_round_trips_and_verifies() {
193 let (identity, owner) = identity_and_owner();
194 let wrap = tombstone_for(&identity, &owner);
195 assert_eq!(open_dissolved(&wrap, &identity.community_id).unwrap().owner, owner.public_key());
196 assert!(verify_dissolved(&wrap, &identity));
197 }
198
199 #[test]
200 fn a_non_owner_tombstone_is_not_death() {
201 let (identity, _owner) = identity_and_owner();
202 let impostor = Keys::generate();
205 let rumor = dissolved_tombstone_rumor(impostor.public_key(), &identity.community_id, 1_725_000_000);
206 let wrap = seal_dissolved(&rumor, &identity.community_id, &impostor, Timestamp::from_secs(1_725_000_000)).unwrap();
207
208 assert_eq!(open_dissolved(&wrap, &identity.community_id).unwrap().owner, impostor.public_key());
210 assert!(!verify_dissolved(&wrap, &identity), "a foreign-signed tombstone is not death");
211 }
212
213 #[test]
214 fn a_non_self_certifying_identity_fails_closed() {
215 let (identity, owner) = identity_and_owner();
216 let wrap = tombstone_for(&identity, &owner);
217
218 let attacker = Keys::generate();
220 let forged = CommunityIdentity {
221 community_id: identity.community_id,
222 owner_xonly: attacker.public_key().to_bytes(),
223 owner_salt: identity.owner_salt,
224 };
225 assert!(!forged.verify());
226 assert!(!verify_dissolved(&wrap, &forged), "an identity that doesn't self-certify can't accept a tombstone");
227 }
228
229 #[test]
230 fn the_address_is_community_id_derived_and_epoch_free() {
231 let (identity, owner) = identity_and_owner();
232 let wrap = tombstone_for(&identity, &owner);
233
234 let a = dissolved_group_key(&identity.community_id);
237 let b = dissolved_group_key(&identity.community_id);
238 assert_eq!(a.pk_hex(), b.pk_hex());
239 assert_eq!(open_dissolved(&wrap, &identity.community_id).unwrap().owner, owner.public_key());
240
241 let other = CommunityIdentity::mint(&owner.public_key());
243 assert!(open_dissolved(&wrap, &other.community_id).is_err());
244 assert!(!verify_dissolved(&wrap, &other));
245 }
246
247 #[test]
248 fn a_tombstone_cannot_be_replayed_onto_another_community_of_the_same_owner() {
249 let owner = Keys::generate();
255 let x = CommunityIdentity::mint(&owner.public_key());
256 let y = CommunityIdentity::mint(&owner.public_key());
257 assert_ne!(x.community_id, y.community_id);
258
259 let wrap_x = tombstone_for(&x, &owner);
260 assert!(verify_dissolved(&wrap_x, &x));
262
263 let x_group = dissolved_group_key(&x.community_id);
265 let opened_x = stream::open_wrap(&wrap_x, &x_group).unwrap();
266 let y_group = dissolved_group_key(&y.community_id);
267 let (replayed, _) = stream::wrap_seal(&opened_x.seal, &y_group, stream::KIND_WRAP, Timestamp::from_secs(1_725_000_100)).unwrap();
268
269 assert!(matches!(open_dissolved(&replayed, &y.community_id), Err(DissolveError::NotATombstone)));
272 assert!(!verify_dissolved(&replayed, &y), "an X tombstone must never kill Y");
273 }
274
275 #[test]
276 fn the_tombstone_rumor_is_chainless_and_binds_the_community() {
277 let owner = Keys::generate();
278 let cid = CommunityId([0x5a; 32]);
279 let rumor = dissolved_tombstone_rumor(owner.public_key(), &cid, 1_725_000_000);
280 assert_eq!(rumor.kind.as_u16(), kind::CONTROL);
281 assert!(rumor.content.is_empty());
282 assert_eq!(first_tag(&rumor, TAG_VSK).as_deref(), Some(vsk::DISSOLVED));
283 assert_eq!(first_tag(&rumor, TAG_EID).as_deref(), Some(crate::simd::hex::bytes_to_hex_32(&cid.0).as_str()));
285 assert_ne!(first_tag(&rumor, TAG_EID).as_deref(), Some(crate::simd::hex::bytes_to_hex_32(&[0u8; 32]).as_str()));
286 for machinery in ["ev", "ep", "vac"] {
288 assert!(first_tag(&rumor, machinery).is_none(), "chainless: {machinery} must be absent");
289 }
290 }
291}