Skip to main content

nula_core/event/
coordinate.rs

1//! Address of a parameterized replaceable event.
2//!
3//! NIP-01 specifies that the triple `(kind, author, identifier)` uniquely
4//! identifies a *parameterized replaceable* event, i.e. an event whose
5//! `kind` is in `30000..=39999` and whose `d` tag carries the identifier.
6//!
7//! The on-the-wire encoding is the colon-separated form used by `a` tags
8//! (NIP-01 §addressable events) and shared with NIP-19 `naddr`:
9//!
10//! ```text
11//! <kind>:<author-pubkey-hex>:<identifier>
12//! ```
13//!
14//! [`Coordinate`] models that triple, exposes `Display`/`FromStr` for the
15//! wire form, and is reused by NIP-09 (`a` tag in deletion events), NIP-19
16//! (`naddr`), and any future NIP that addresses replaceable events.
17
18use std::fmt;
19use std::str::FromStr;
20
21use serde::{Deserialize, Deserializer, Serialize, Serializer};
22use thiserror::Error;
23
24use super::kind::Kind;
25use crate::key::{PublicKey, PublicKeyError};
26
27/// Errors raised when parsing a [`Coordinate`] from its wire form.
28#[derive(Debug, Clone, Error)]
29#[non_exhaustive]
30pub enum CoordinateError {
31    /// The wire form did not contain exactly two `:` separators.
32    #[error("expected `<kind>:<author>:<identifier>`, got `{0}`")]
33    Malformed(String),
34    /// The `kind` segment did not parse as an unsigned 16-bit integer.
35    #[error("invalid kind segment `{0}`")]
36    InvalidKind(String),
37    /// The `author` segment did not parse as a 32-byte public key.
38    #[error(transparent)]
39    InvalidAuthor(#[from] PublicKeyError),
40}
41
42/// Address of a parameterized replaceable event.
43#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
44pub struct Coordinate {
45    /// Event kind.
46    pub kind: Kind,
47    /// Author's public key.
48    pub author: PublicKey,
49    /// `d`-tag identifier.
50    pub identifier: String,
51}
52
53impl Coordinate {
54    /// Construct a coordinate.
55    #[must_use]
56    pub fn new(kind: Kind, author: PublicKey, identifier: impl Into<String>) -> Self {
57        Self {
58            kind,
59            author,
60            identifier: identifier.into(),
61        }
62    }
63
64    /// Parse the colon-separated wire form `<kind>:<author>:<identifier>`.
65    ///
66    /// Equivalent to `s.parse::<Coordinate>()` but matches the
67    /// `Type::parse` naming convention used elsewhere in the crate
68    /// ([`PublicKey::parse`], [`crate::types::RelayUrl::parse`],
69    /// [`crate::nips::nip46::Uri::parse`]).
70    ///
71    /// # Errors
72    ///
73    /// See [`CoordinateError`].
74    pub fn parse(input: impl AsRef<str>) -> Result<Self, CoordinateError> {
75        input.as_ref().parse()
76    }
77
78    /// Render the colon-separated wire form.
79    #[must_use]
80    pub fn to_wire(&self) -> String {
81        format!(
82            "{}:{}:{}",
83            self.kind.as_u16(),
84            self.author.to_hex(),
85            self.identifier
86        )
87    }
88}
89
90impl fmt::Display for Coordinate {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        f.write_str(&self.to_wire())
93    }
94}
95
96impl FromStr for Coordinate {
97    type Err = CoordinateError;
98
99    fn from_str(s: &str) -> Result<Self, Self::Err> {
100        let mut parts = s.splitn(3, ':');
101        let kind_str = parts.next();
102        let author_str = parts.next();
103        let identifier = parts.next();
104        match (kind_str, author_str, identifier) {
105            (Some(k), Some(a), Some(id)) => {
106                let kind: u16 = k
107                    .parse()
108                    .map_err(|_| CoordinateError::InvalidKind(k.to_owned()))?;
109                let author = PublicKey::parse(a)?;
110                Ok(Self::new(Kind::from(kind), author, id))
111            }
112            _ => Err(CoordinateError::Malformed(s.to_owned())),
113        }
114    }
115}
116
117impl Serialize for Coordinate {
118    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
119    where
120        S: Serializer,
121    {
122        serializer.serialize_str(&self.to_wire())
123    }
124}
125
126impl<'de> Deserialize<'de> for Coordinate {
127    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
128    where
129        D: Deserializer<'de>,
130    {
131        let raw = String::deserialize(deserializer)?;
132        raw.parse().map_err(serde::de::Error::custom)
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::Keys;
140
141    fn pk() -> PublicKey {
142        let keys = Keys::parse("0000000000000000000000000000000000000000000000000000000000000003")
143            .unwrap();
144        *keys.public_key()
145    }
146
147    #[test]
148    fn display_round_trip() {
149        let coord = Coordinate::new(Kind::from(30_023_u16), pk(), "long-form-1");
150        let wire = coord.to_string();
151        let parsed: Coordinate = wire.parse().unwrap();
152        assert_eq!(parsed, coord);
153    }
154
155    #[test]
156    fn allows_colon_in_identifier() {
157        // Identifiers may contain `:` (NIP-01 doesn't forbid it); nula's
158        // `splitn(3, ':')` keeps everything after the second colon in the
159        // third segment, so a multi-colon identifier round-trips intact.
160        //
161        // Interop note: `rust-nostr` 0.45 parses coordinates with
162        // `coordinate.split(':')` and takes only the first three segments
163        // (`nip01/mod.rs::from_kpi_format`), which *truncates* this
164        // identifier to `"weird"` and silently drops `:id:with:colons`.
165        // nula's behaviour is the spec-faithful one; this test pins the
166        // divergence so it stays intentional and visible.
167        let coord = Coordinate::new(Kind::from(30_023_u16), pk(), "weird:id:with:colons");
168        let wire = coord.to_string();
169        let parsed: Coordinate = wire.parse().unwrap();
170        assert_eq!(parsed, coord);
171        assert_eq!(parsed.identifier, "weird:id:with:colons");
172
173        // Pin the exact divergence point: parsing a hand-built wire string
174        // keeps the full colon-bearing tail as the identifier rather than
175        // truncating at the first colon (what `split(':')` would do).
176        let tail_wire = format!("30023:{}:a:b:c", pk().to_hex());
177        let tail_parsed: Coordinate = tail_wire.parse().unwrap();
178        assert_eq!(tail_parsed.identifier, "a:b:c");
179    }
180
181    #[test]
182    fn rejects_missing_components() {
183        let err1 = "30023".parse::<Coordinate>().unwrap_err();
184        assert!(matches!(err1, CoordinateError::Malformed(_)));
185        let err2 = "30023:not-hex".parse::<Coordinate>().unwrap_err();
186        assert!(matches!(err2, CoordinateError::Malformed(_)));
187    }
188
189    #[test]
190    fn rejects_bad_kind() {
191        let value = format!("not-a-number:{}:foo", pk().to_hex());
192        let err: CoordinateError = value.parse::<Coordinate>().unwrap_err();
193        assert!(matches!(err, CoordinateError::InvalidKind(_)));
194    }
195
196    #[test]
197    fn parse_method_matches_fromstr() {
198        // Inherent `parse` and the FromStr impl must produce identical
199        // results — they share the same code path, but pin the
200        // contract with a regression test to guard future refactors.
201        let coord = Coordinate::new(Kind::from(30_023_u16), pk(), "alpha");
202        let wire = coord.to_string();
203        let via_inherent = Coordinate::parse(&wire).unwrap();
204        let via_fromstr: Coordinate = wire.parse().unwrap();
205        assert_eq!(via_inherent, via_fromstr);
206        assert_eq!(via_inherent, coord);
207    }
208
209    #[test]
210    fn serde_uses_wire_form() {
211        let coord = Coordinate::new(Kind::from(30_023_u16), pk(), "alpha");
212        let json = serde_json::to_string(&coord).unwrap();
213        assert!(json.starts_with('"'));
214        assert!(json.contains(":alpha\""));
215        let parsed: Coordinate = serde_json::from_str(&json).unwrap();
216        assert_eq!(parsed, coord);
217    }
218}