Skip to main content

nula_core/event/
unsigned.rs

1//! Event before its signature has been attached.
2//!
3//! [`UnsignedEvent`] is the value handed to a signer (local [`Keys`], NIP-46
4//! remote signer, NIP-07 browser extension, …). The cryptographic identifier
5//! is computed eagerly so the signer only needs to produce a 64-byte Schnorr
6//! signature over `id`.
7//!
8//! [`Keys`]: crate::Keys
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13use super::compute_event_id;
14use super::event::Event;
15use super::id::EventId;
16use super::kind::Kind;
17use super::tag::Tags;
18use crate::key::{Keys, PublicKey};
19use crate::types::Timestamp;
20
21/// Errors raised when signing an [`UnsignedEvent`].
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
23#[non_exhaustive]
24pub enum UnsignedEventError {
25    /// The signer's public key did not match the event author.
26    #[error("signer public key does not match event pubkey")]
27    SignerMismatch,
28}
29
30/// An event whose `id` has been computed but no signature attached.
31///
32/// `Display`, `serde` and equality compare every field. Two unsigned events
33/// produced from identical inputs are guaranteed to compare equal.
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
35pub struct UnsignedEvent {
36    /// SHA-256 of the canonical serialization (NIP-01).
37    pub id: EventId,
38    /// Author's BIP-340 x-only public key.
39    pub pubkey: PublicKey,
40    /// Author-supplied creation timestamp.
41    pub created_at: Timestamp,
42    /// Event kind (NIP-01).
43    pub kind: Kind,
44    /// Event tags.
45    pub tags: Tags,
46    /// Event content.
47    pub content: String,
48}
49
50impl UnsignedEvent {
51    /// Build an [`UnsignedEvent`] by computing the [`EventId`] from `pubkey`
52    /// and the other fields.
53    #[must_use]
54    pub fn new(
55        pubkey: PublicKey,
56        created_at: Timestamp,
57        kind: Kind,
58        tags: Tags,
59        content: impl Into<String>,
60    ) -> Self {
61        let content = content.into();
62        let id = compute_event_id(&pubkey, created_at, kind, &tags, &content);
63        Self {
64            id,
65            pubkey,
66            created_at,
67            kind,
68            tags,
69            content,
70        }
71    }
72
73    /// Recompute the [`EventId`] from the current fields and return it.
74    ///
75    /// Useful when fields were mutated through the public struct API.
76    #[must_use]
77    pub fn compute_id(&self) -> EventId {
78        compute_event_id(
79            &self.pubkey,
80            self.created_at,
81            self.kind,
82            &self.tags,
83            &self.content,
84        )
85    }
86
87    /// Sign this event with `keys`.
88    ///
89    /// The signer's public key must match `self.pubkey`; this protects against
90    /// silently mis-signing on behalf of another author.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`UnsignedEventError::SignerMismatch`] if the signer's
95    /// public key does not match `self.pubkey`.
96    ///
97    /// # Observability
98    ///
99    /// When the `tracing` feature is enabled, a `debug`-level span
100    /// `nula.event.sign` is opened for the full signing path. `keys`
101    /// is `skip`-ped so secret material never reaches a subscriber;
102    /// only the non-secret kind / content-size / tag-count appear.
103    #[cfg_attr(
104        feature = "tracing",
105        tracing::instrument(
106            level = "debug",
107            name = "nula.event.sign",
108            skip(self, keys),
109            fields(
110                nostr.event.kind = self.kind.as_u16(),
111                nostr.event.content_size = self.content.len(),
112                nostr.event.tag_count = self.tags.len(),
113            ),
114        )
115    )]
116    pub fn sign_with_keys(self, keys: &Keys) -> Result<Event, UnsignedEventError> {
117        if keys.public_key() != &self.pubkey {
118            #[cfg(feature = "tracing")]
119            tracing::debug!("signer public key does not match unsigned event author");
120            return Err(UnsignedEventError::SignerMismatch);
121        }
122
123        // Recompute the id from the current fields rather than trusting the
124        // `id` already on the struct: callers can mutate fields between
125        // construction and signing.
126        let canonical_id = self.compute_id();
127        let signature = keys.sign_schnorr(&canonical_id.to_byte_array());
128
129        Ok(Event::from_parts(
130            canonical_id,
131            self.pubkey,
132            self.created_at,
133            self.kind,
134            self.tags,
135            self.content,
136            signature,
137        ))
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn fixture_keys() -> Keys {
146        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
147    }
148
149    #[test]
150    fn new_computes_id() {
151        let keys = fixture_keys();
152        let unsigned = UnsignedEvent::new(
153            *keys.public_key(),
154            Timestamp::from_secs(1_700_000_000),
155            Kind::TEXT_NOTE,
156            Tags::new(),
157            "hello",
158        );
159        assert_eq!(unsigned.id, unsigned.compute_id());
160    }
161
162    #[test]
163    fn sign_with_keys_produces_event() {
164        let keys = fixture_keys();
165        let unsigned = UnsignedEvent::new(
166            *keys.public_key(),
167            Timestamp::from_secs(1_700_000_000),
168            Kind::TEXT_NOTE,
169            Tags::new(),
170            "hello",
171        );
172        let event = unsigned.clone().sign_with_keys(&keys).unwrap();
173        assert_eq!(event.id, unsigned.id);
174        assert_eq!(event.pubkey, unsigned.pubkey);
175        assert_eq!(event.content, unsigned.content);
176        event.verify().unwrap();
177    }
178
179    #[test]
180    fn sign_with_keys_rejects_mismatch() {
181        let alice = fixture_keys();
182        let bob = Keys::parse("0000000000000000000000000000000000000000000000000000000000000005")
183            .unwrap();
184        let unsigned = UnsignedEvent::new(
185            *alice.public_key(),
186            Timestamp::from_secs(1_700_000_000),
187            Kind::TEXT_NOTE,
188            Tags::new(),
189            "hello",
190        );
191        let err = unsigned.sign_with_keys(&bob).unwrap_err();
192        assert_eq!(err, UnsignedEventError::SignerMismatch);
193    }
194
195    #[test]
196    fn serde_round_trip() {
197        let keys = fixture_keys();
198        let unsigned = UnsignedEvent::new(
199            *keys.public_key(),
200            Timestamp::from_secs(1),
201            Kind::TEXT_NOTE,
202            Tags::new(),
203            "hi",
204        );
205        let json = serde_json::to_string(&unsigned).unwrap();
206        let parsed: UnsignedEvent = serde_json::from_str(&json).unwrap();
207        assert_eq!(parsed, unsigned);
208    }
209}