diff --git a/Cargo.toml b/Cargo.toml index d01d06f..d385abf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nostr-nostd" -version = "0.1.0" +version = "0.2.0" description = "A crate to assist with implementing a nostr client in a ![no_std] environment" documentation = "https://docs.rs/nostr-nostd" repository = "https://github.com/isaac-asdf/nostr-nostd" @@ -16,5 +16,10 @@ edition = "2021" [dependencies] base16ct = "0.2.0" heapless = { version = "0.7.14", default-features = false } -secp256k1 = {version = "0.27.0", default-features = false, features = ["serde", "rand", "recovery", "lowmemory"] } -sha2 = { version = "0.10.7", default-features = false } \ No newline at end of file +secp256k1 = {version = "0.27.0", default-features = false, features = ["lowmemory"] } +sha2 = { version = "0.10.7", default-features = false } + +# NIP04 +aes = "0.8.3" +cbc = { version = "0.1.2", default-features = false } +base64ct = "1.6.0" diff --git a/README.md b/README.md index cafdb52..36fd0a4 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,16 @@ A demo project can be seen [here](https://github.com/isaac-asdf/esp32-nostr-clie # Implemented -- Basic Kind 1 note creation -- Tag addition, up to 5 +- Kinds implemented + - ShortNote, 1 + - DMs, 4 + - Auth, 22242 +- Tags on notes, limit of 5 # Future improvements -- Support more note kinds, investigate Kind 4 for private sending in an IOT context +- Support more note kinds +- Investigate GenericArray to make length of content able to be larger without always filling memory [//]: # "badges" [crate-image]: https://buildstats.info/crate/nostr-nostd diff --git a/src/errors.rs b/src/errors.rs index 94bca47..ec0773e 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,7 +1,22 @@ +//! Possible errors thrown by this crate + #[derive(PartialEq, Debug)] -pub enum ResponseErrors { +pub enum Error { + InvalidPubkey, + InvalidPrivkey, + InternalPubkeyError, + InternalSigningError, + TagNameTooLong, + UnknownKind, InvalidType, TypeNotAccepted, MalformedContent, ContentOverflow, + EventNotValid, + EventMissingField, + TooManyTags, + InternalError, + EncodeError, + Secp256k1Error, + QueryBuilderOverflow, } diff --git a/src/lib.rs b/src/lib.rs index a91a445..7622ee8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,58 +1,111 @@ #![no_std] -//! Implementation of [Nostr](https://nostr.com/) note creation for a #![no_std] environment. +//! Implementation of [Nostr](https://nostr.com/) for a #![no_std] environment. It supports note creation and parsing relay responses. //! An example project using an esp32 can be seen [here](https://github.com/isaac-asdf/esp32-nostr-client). //! //! # Example //! ``` -//! use nostr_nostd::{Note, String}; -//! const PRIVKEY: &str = "a5084b35a58e3e1a26f5efb46cb9dbada73191526aa6d11bccb590cbeb2d8fa3"; -//! let content: String<100> = String::from("Hello, World!"); -//! let tag: String<64> = String::from(r#"["relay", "wss://relay.example.com/"]"#); +//! use nostr_nostd::{Note, String, ClientMsgKinds}; +//! let privkey = "a5084b35a58e3e1a26f5efb46cb9dbada73191526aa6d11bccb590cbeb2d8fa3"; +//! let content: String<400> = String::from("Hello, World!"); +//! let tag: String<150> = String::from("relay,wss://relay.example.com/"); //! // aux_rand should be generated from a random number generator //! // required to keep PRIVKEY secure with Schnorr signatures //! let aux_rand = [0; 32]; -//! let note = Note::new() +//! let note = Note::new_builder(privkey) +//! .unwrap() //! .content(content) //! .add_tag(tag) -//! .created_at(1686880020) -//! .build(PRIVKEY, aux_rand); -//! let msg = note.serialize_to_relay(); +//! .build(1686880020, aux_rand) +//! .unwrap(); +//! let msg = note.serialize_to_relay(ClientMsgKinds::Event); //! ``` //! + pub use heapless::{String, Vec}; -use secp256k1::{self, ffi::types::AlignedType, KeyPair, Message}; +use relay_responses::AuthMessage; +use secp256k1::{self, ffi::types::AlignedType, KeyPair, Message, XOnlyPublicKey}; use sha2::{Digest, Sha256}; +use utils::to_decimal_str; pub mod errors; +mod nip04; +mod parse_json; +pub mod query; pub mod relay_responses; +mod utils; + +const TAG_SIZE: usize = 150; +const NOTE_SIZE: usize = 400; +const MAX_DM_SIZE: usize = 400; /// Defined by the [nostr protocol](https://github.com/nostr-protocol/nips/tree/master#event-kinds) -#[derive(Copy, Clone)] +#[derive(Debug, Copy, Clone, PartialEq)] pub enum NoteKinds { /// For most short text based notes - ShortNote = 1, + ShortNote, + /// DM + DM, + /// IOT Event, + IOT, /// Ephemeral event for authentication to relay - Auth = 22242, + Auth, + /// Regular Events (must be between 1000 and <=9999) + Regular(u16), + /// Replacabe event (must be between 10000 and <20000) + Replaceable(u16), + /// Ephemeral event (must be between 20000 and <30000) + Ephemeral(u16), + /// Parameterized Replacabe event (must be between 30000 and <40000) + ParameterizedReplaceable(u16), + /// Custom + Custom(u16), } impl NoteKinds { - pub fn serialize(&self) -> [u8; 10] { + pub fn serialize(&self) -> String<10> { // will ignore large bytes when serializing - let mut buffer = [255_u8; 10]; - let mut idx = buffer.len(); - let mut n = *self as u32; - - while n > 0 && idx > 0 { - idx -= 1; - buffer[idx] = b'0' + (n % 10) as u8; - n /= 10; - } + let n: u16 = match self { + NoteKinds::ShortNote => 1, + NoteKinds::DM => 4, + NoteKinds::IOT => 5732, + NoteKinds::Auth => 22242, + NoteKinds::Regular(val) => *val, + NoteKinds::Replaceable(val) => *val, + NoteKinds::Ephemeral(val) => *val, + NoteKinds::ParameterizedReplaceable(val) => *val, + NoteKinds::Custom(val) => *val, + }; + + to_decimal_str(n as u32) + } +} - buffer +impl From for NoteKinds { + fn from(value: u16) -> Self { + match value { + 1 => NoteKinds::ShortNote, + 4 => NoteKinds::DM, + 5732 => NoteKinds::IOT, + 22242 => NoteKinds::Auth, + x if (1_000..10_000).contains(&x) => NoteKinds::Regular(x as u16), + x if (10_000..20_000).contains(&x) => NoteKinds::Replaceable(x as u16), + x if (20_000..30_000).contains(&x) => NoteKinds::Ephemeral(x as u16), + x if (30_000..40_000).contains(&x) => NoteKinds::ParameterizedReplaceable(x as u16), + x => NoteKinds::Custom(x), + } } } +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum ClientMsgKinds { + Event, + Req, + Auth, + Close, +} + /// Representation of Nostr Note +#[derive(Debug, PartialEq)] pub struct Note { /// ID of note id: [u8; 64], @@ -62,16 +115,11 @@ pub struct Note { created_at: u32, /// Default to kind 1 kind: NoteKinds, - tags: Vec, 5>, - content: Option>, + tags: Vec, 5>, + content: Option>, sig: [u8; 128], } -/// NoteBuilder has had `.created_at()` called and is ready for `.build()` -pub struct TimeSet; -/// NoteBuilder is waiting for `.created_at()` prior to `.build()` -pub struct TimeNotSet; - /// Impl for tags which can had an additional tag added. /// ie, not implemented for FiveTags but implemented for all others pub trait AddTag { @@ -104,48 +152,53 @@ impl TagCount for FiveTags {} impl AddTag for ZeroTags { type Next = OneTag; // Implement the next method to return a new MyType instance + #[inline] fn next(self) -> OneTag { OneTag } } impl AddTag for OneTag { type Next = TwoTags; + #[inline] fn next(self) -> TwoTags { TwoTags } } impl AddTag for TwoTags { type Next = ThreeTags; + #[inline] fn next(self) -> ThreeTags { ThreeTags } } impl AddTag for ThreeTags { type Next = FourTags; + #[inline] fn next(self) -> FourTags { FourTags } } impl AddTag for FourTags { type Next = FiveTags; + #[inline] fn next(self) -> FiveTags { FiveTags } } /// Used to track the addition of the time created and the number of tags added -pub struct BuildStatus { - time: A, +pub struct BuildStatus { tags: B, } /// Used to fill in the fields of a Note. -pub struct NoteBuilder { - build_status: BuildStatus, +pub struct NoteBuilder { + keypair: KeyPair, + build_status: BuildStatus, note: Note, } -impl NoteBuilder +impl NoteBuilder where T: AddTag, NextAddTag: TagCount, @@ -153,21 +206,8 @@ where /// Adds a new tag to the note. /// The maximum number of tags currently allowed is 5. /// Attempts to add too many tags will be a compilation error. - /// - /// # Example - /// ``` - /// use nostr_nostd::{Note, String}; - /// const PRIVKEY: &str = "a5084b35a58e3e1a26f5efb46cb9dbada73191526aa6d11bccb590cbeb2d8fa3"; - /// let content: String<100> = String::from("i have tags"); - /// let tag: String<64> = String::from(r#"["relay", "wss://relay.example.com/"]"#); - /// let note = Note::new() - /// .content(content) - /// .add_tag(tag) - /// .created_at(1690076405) - /// .build(PRIVKEY, [0; 32]); - /// ``` - - pub fn add_tag(mut self, tag: String<64>) -> NoteBuilder { + #[inline] + pub fn add_tag(mut self, tag: String) -> NoteBuilder { let next_tags = self.build_status.tags.next(); self.note .tags @@ -175,16 +215,14 @@ where .expect("AddTag impl error, should be impossible to err here"); NoteBuilder { - build_status: BuildStatus { - time: self.build_status.time, - tags: next_tags, - }, + build_status: BuildStatus { tags: next_tags }, + keypair: self.keypair, note: self.note, } } } -impl NoteBuilder { +impl NoteBuilder { /// Sets the "kind" field of the note pub fn set_kind(mut self, kind: NoteKinds) -> Self { self.note.kind = kind; @@ -192,49 +230,85 @@ impl NoteBuilder { } /// Sets the "content" field of Note - pub fn content(mut self, content: String<100>) -> Self { + pub fn content(mut self, content: String) -> Self { self.note.content = Some(content); self } } -impl NoteBuilder { - /// Must be called prior to build() to set the time of the note - pub fn created_at(mut self, created_at: u32) -> NoteBuilder { - self.note.created_at = created_at; - NoteBuilder { - build_status: BuildStatus { - time: TimeSet, - tags: self.build_status.tags, - }, +impl NoteBuilder { + /// Creates an auth note per NIP42 + #[inline] + pub fn create_auth( + mut self, + auth: &AuthMessage, + relay: &str, + ) -> Result, errors::Error> { + let mut tags = Vec::new(); + let mut challenge_string: String = String::from("challenge,"); + challenge_string + .push_str(&auth.challenge_string) + .map_err(|_| errors::Error::ContentOverflow)?; + tags.push(challenge_string).expect("impossible"); + let mut relay_str: String = String::from("relay,"); + relay_str + .push_str(relay) + .map_err(|_| errors::Error::ContentOverflow)?; + tags.push(relay_str).expect("impossible"); + self.note.tags = tags; + self.note.kind = NoteKinds::Auth; + Ok(NoteBuilder { + keypair: self.keypair, note: self.note, - } + build_status: BuildStatus { tags: TwoTags }, + }) + } + + /// Sets the "content" field according to NIP04 and adds the tag for receiver pubkey. + /// iv should be generated from a random source + #[inline] + pub fn create_dm( + mut self, + content: &str, + rcvr_pubkey: &str, + iv: [u8; 16], + ) -> Result, errors::Error> { + let mut msg = [0_u8; 32]; + base16ct::lower::decode(&rcvr_pubkey, &mut msg) + .map_err(|_| errors::Error::InvalidPubkey)?; + let pubkey = XOnlyPublicKey::from_slice(&msg).map_err(|_| errors::Error::InvalidPubkey)?; + let encrypted = nip04::encrypt(&self.keypair.secret_key(), &pubkey, content, iv)?; + self.note.content = Some(encrypted); + let mut tag = String::from("p,"); + tag.push_str(rcvr_pubkey).expect("impossible"); + Ok(self.add_tag(tag)) } } -impl NoteBuilder { - pub fn build(mut self, privkey: &str, aux_rnd: [u8; 32]) -> Note { - self.note.set_pubkey(privkey); - self.note.set_id(); - self.note.set_sig(privkey, &aux_rnd); - self.note +impl NoteBuilder { + /// Set the 'created_at' and sign the note. + #[inline] + pub fn build(mut self, created_at: u32, aux_rnd: [u8; 32]) -> Result { + self.note.created_at = created_at; + self.note.set_pubkey(&self.keypair.x_only_public_key().0)?; + self.note.set_id()?; + self.note.set_sig(&self.keypair, &aux_rnd)?; + Ok(self.note) } } impl Note { - /// Returns a new Note - /// # Arguments - /// - /// * `content` - data to be included in "content" field - /// * `aux_rnd` - MUST be unique for each note created to avoid leaking private key - /// * `created_at` - Unix timestamp for note creation time - /// - pub fn new() -> NoteBuilder { - NoteBuilder { - build_status: BuildStatus { - time: TimeNotSet, - tags: ZeroTags, - }, + /// Returns a NoteBuilder, can error if the privkey is invalid + #[inline] + pub fn new_builder(privkey: &str) -> Result, errors::Error> { + let mut buf = [AlignedType::zeroed(); 64]; + let sig_obj = secp256k1::Secp256k1::preallocated_new(&mut buf) + .map_err(|_| errors::Error::Secp256k1Error)?; + let key_pair: KeyPair = KeyPair::from_seckey_str(&sig_obj, privkey) + .map_err(|_| errors::Error::InvalidPrivkey)?; + Ok(NoteBuilder { + build_status: BuildStatus { tags: ZeroTags }, + keypair: key_pair, note: Note { id: [0; 64], pubkey: [0; 64], @@ -244,22 +318,11 @@ impl Note { content: None, sig: [0; 128], }, - } + }) } - fn timestamp_bytes(&self) -> [u8; 10] { - // thanks to ChatGPT for the below code :) - let mut buffer = [255_u8; 10]; - let mut idx = buffer.len(); - let mut n = self.created_at; - - while n > 0 && idx > 0 { - idx -= 1; - buffer[idx] = b'0' + (n % 10) as u8; - n /= 10; - } - - buffer + fn timestamp_bytes(&self) -> String<10> { + to_decimal_str(self.created_at) } fn to_hash_str(&self) -> ([u8; 1536], usize) { @@ -277,19 +340,15 @@ impl Note { hash_str[count] = *bs; count += 1; }); - self.timestamp_bytes().iter().for_each(|bs| { - if *bs != 255 { - hash_str[count] = *bs; - count += 1; - } + self.timestamp_bytes().chars().for_each(|bs| { + hash_str[count] = bs as u8; + count += 1; }); hash_str[count] = 44; // 44 = , count += 1; - self.kind.serialize().iter().for_each(|bs| { - if *bs != 255 { - hash_str[count] = *bs; - count += 1; - } + self.kind.serialize().chars().for_each(|bs| { + hash_str[count] = bs as u8; + count += 1; }); hash_str[count] = 44; // 44 = , count += 1; @@ -298,12 +357,41 @@ impl Note { hash_str[count] = *bs; count += 1; }); + let mut tags_present = false; self.tags.iter().for_each(|tag| { - tag.as_bytes().iter().for_each(|bs| { - hash_str[count] = *bs; + // add opening [ + hash_str[count] = 91; + count += 1; + tag.split(",").for_each(|element| { + // add opening " + hash_str[count] = 34; count += 1; - }) + element.as_bytes().iter().for_each(|bs| { + hash_str[count] = *bs; + count += 1; + }); + // add closing " + hash_str[count] = 34; + count += 1; + // add , separator back in + hash_str[count] = 44; + count += 1; + }); + // remove last comma + count -= 1; + // add closing ] + hash_str[count] = 93; + count += 1; + + // add closing , + hash_str[count] = 44; + count += 1; + tags_present = true; }); + if tags_present { + // remove last comma + count -= 1; + } br#"],""#.iter().for_each(|bs| { hash_str[count] = *bs; count += 1; @@ -321,34 +409,38 @@ impl Note { (hash_str, count) } - fn set_pubkey(&mut self, privkey: &str) { - let mut buf = [AlignedType::zeroed(); 64]; - let sig_obj = secp256k1::Secp256k1::preallocated_new(&mut buf).unwrap(); - let key_pair = KeyPair::from_seckey_str(&sig_obj, privkey).expect("priv key failed"); - let pubkey = &key_pair.public_key().serialize()[1..33]; - base16ct::lower::encode(pubkey, &mut self.pubkey).expect("encode error"); + fn set_pubkey(&mut self, pubkey: &XOnlyPublicKey) -> Result<(), errors::Error> { + let pubkey = &pubkey.serialize(); + base16ct::lower::encode(pubkey, &mut self.pubkey) + .map_err(|_| errors::Error::EncodeError)?; + Ok(()) } - fn set_id(&mut self) { + fn set_id(&mut self) -> Result<(), errors::Error> { let (remaining, len) = self.to_hash_str(); let mut hasher = Sha256::new(); hasher.update(&remaining[..len]); let results = hasher.finalize(); - base16ct::lower::encode(&results, &mut self.id).expect("encode error"); + base16ct::lower::encode(&results, &mut self.id).map_err(|_| errors::Error::EncodeError)?; + Ok(()) } - fn set_sig(&mut self, privkey: &str, aux_rnd: &[u8; 32]) { + fn set_sig(&mut self, key_pair: &KeyPair, aux_rnd: &[u8; 32]) -> Result<(), errors::Error> { // figure out what size we need and why let mut buf = [AlignedType::zeroed(); 64]; - let sig_obj = secp256k1::Secp256k1::preallocated_new(&mut buf).unwrap(); + let sig_obj = secp256k1::Secp256k1::preallocated_new(&mut buf) + .map_err(|_| errors::Error::Secp256k1Error)?; let mut msg = [0_u8; 32]; - base16ct::lower::decode(&self.id, &mut msg).expect("encode error"); + base16ct::lower::decode(&self.id, &mut msg) + .map_err(|_| errors::Error::InternalSigningError)?; - let message = Message::from_slice(&msg).expect("32 bytes"); - let key_pair = KeyPair::from_seckey_str(&sig_obj, privkey).expect("priv key failed"); - let sig = sig_obj.sign_schnorr_with_aux_rand(&message, &key_pair, aux_rnd); - base16ct::lower::encode(sig.as_ref(), &mut self.sig).expect("encode error"); + let message = Message::from_slice(&msg).map_err(|_| errors::Error::InternalSigningError)?; + + let sig = sig_obj.sign_schnorr_with_aux_rand(&message, key_pair, aux_rnd); + base16ct::lower::encode(sig.as_ref(), &mut self.sig) + .map_err(|_| errors::Error::EncodeError)?; + Ok(()) } fn to_json(&self) -> Vec { @@ -371,12 +463,10 @@ impl Note { .push(*bs) .expect("Impossible due to size constraints of content, tags"); }); - self.timestamp_bytes().iter().for_each(|bs| { - if *bs != 255 { - output - .push(*bs) - .expect("Impossible due to size constraints of content, tags"); - } + self.timestamp_bytes().chars().for_each(|bs| { + output + .push(bs as u8) + .expect("Impossible due to size constraints of content, tags"); }); br#","id":""#.iter().for_each(|bs| { output @@ -393,12 +483,10 @@ impl Note { .push(*bs) .expect("Impossible due to size constraints of content, tags"); }); - self.kind.serialize().iter().for_each(|bs| { - if *bs != 255 { - output - .push(*bs) - .expect("Impossible due to size constraints of content, tags"); - } + self.kind.serialize().chars().for_each(|bs| { + output + .push(bs as u8) + .expect("Impossible due to size constraints of content, tags"); }); br#","pubkey":""#.iter().for_each(|bs| { output @@ -425,13 +513,33 @@ impl Note { .push(*bs) .expect("Impossible due to size constraints of content, tags"); }); + let mut tags_present = false; self.tags.iter().for_each(|tag| { - tag.as_bytes().iter().for_each(|bs| { - output - .push(*bs) - .expect("Impossible due to size constraints of content, tags"); - }) + // add opening [ + output.push(91).expect("impossible"); + tag.split(",").for_each(|element| { + // add opening " + output.push(34).expect("impossible"); + element.as_bytes().iter().for_each(|bs| { + output.push(*bs).expect("impossible"); + }); + // add closing " + output.push(34).expect("impossible"); + // add a comma separator + output.push(44).expect("impossible"); + }); + // remove last comma + output.pop().expect("impossible"); + // add closing ] + output.push(93).expect("impossible"); + // add a comma separator + output.push(44).expect("impossible"); + tags_present = true; }); + if tags_present { + // remove last comma + output.pop().expect("impossible"); + } br#"]}"#.iter().for_each(|bs| { output .push(*bs) @@ -441,15 +549,18 @@ impl Note { output } - /// Serializes the note so it can be sent to a relay - /// # Returns - /// - /// * `[u8; 1000]` - lower case hex encoded byte array of note, to be sent to relay - /// * `usize` - length of the buffer used - pub fn serialize_to_relay(self) -> Vec { + /// Serializes the note for sending to relay + #[inline] + pub fn serialize_to_relay(self, msg_type: ClientMsgKinds) -> Vec { + let wire_lead = match msg_type { + ClientMsgKinds::Event => r#"["EVENT","#, + ClientMsgKinds::Req => r#"["REQ","#, + ClientMsgKinds::Auth => r#"["AUTH","#, + ClientMsgKinds::Close => r#"["CLOSE","#, + }; let mut output: Vec = Vec::new(); // fill in output - br#"["EVENT","#.iter().for_each(|bs| { + wire_lead.as_bytes().iter().for_each(|bs| { output .push(*bs) .expect("Impossible due to size constraints of content, tags"); @@ -465,6 +576,56 @@ impl Note { .expect("Impossible due to size constraints of content, tags"); output } + + /// Get associated values with a given tag name. + /// Returns up to 5 instances for the searched for label. + #[inline] + pub fn get_tag(&self, tag: &str) -> Result, 5>, errors::Error> { + let mut search_tag: String<10> = String::from(tag); + search_tag + .push_str(",") + .map_err(|_| errors::Error::TagNameTooLong)?; + Ok(self + .tags + .iter() + .filter(|my_tag| my_tag.starts_with(search_tag.as_str())) + // each tag will look like tag_name,val1,val2,etc... + .map(|tag| { + let mut splits = tag.split(","); + // remove tag_name from splits + splits.next(); + splits.collect() + }) + .collect()) + } + + /// Decode an encrypted DM + #[inline] + pub fn read_dm(&self, privkey: &str) -> Result, errors::Error> { + let mut buf = [AlignedType::zeroed(); 64]; + let sig_obj = secp256k1::Secp256k1::preallocated_new(&mut buf) + .map_err(|_| errors::Error::Secp256k1Error)?; + let key_pair: KeyPair = KeyPair::from_seckey_str(&sig_obj, privkey) + .map_err(|_| errors::Error::InvalidPrivkey)?; + let sk = key_pair.secret_key(); + let pk_tag = self.get_tag("p")?; + let pk_tag = *pk_tag + .first() + .ok_or(errors::Error::MalformedContent)? + .first() + .ok_or(errors::Error::MalformedContent)?; + let mut msg = [0_u8; 32]; + base16ct::lower::decode(&pk_tag, &mut msg).map_err(|_| errors::Error::EncodeError)?; + let pk = XOnlyPublicKey::from_slice(&msg).map_err(|_| errors::Error::InvalidPubkey)?; + nip04::decrypt( + &sk, + &pk, + self.content + .as_ref() + .ok_or(errors::Error::MalformedContent)? + .as_str(), + ) + } } #[cfg(test)] @@ -473,10 +634,24 @@ mod tests { const PRIVKEY: &str = "a5084b35a58e3e1a26f5efb46cb9dbada73191526aa6d11bccb590cbeb2d8fa3"; fn get_note() -> Note { - Note::new() + Note::new_builder(PRIVKEY) + .unwrap() .content("esptest".into()) - .created_at(1686880020) - .build(PRIVKEY, [0; 32]) + .build(1686880020, [0; 32]) + .expect("infallible") + } + + #[test] + fn test_note_with_tag() { + let note = Note::new_builder(PRIVKEY) + .unwrap() + .content("esptest".into()) + .add_tag("l,bitcoin".into()) + .build(1686880020, [0; 32]) + .expect("infallible"); + let test = note.serialize_to_relay(ClientMsgKinds::Event); + let expected = br#"["EVENT",{"content":"esptest","created_at":1686880020,"id":"f5a693c9a4add3739a4186c0422f925981f75cb1f7a0adfc48852e54973415a6","kind":1,"pubkey":"098ef66bce60dd4cf10b4ae5949d1ec6dd777ddeb4bc49b47f97275a127a63cf","sig":"ff68b2c739f6d19df47c5ae5f150895e11876458afcf8bf169636e55c2b6cce1230d0c54ce9869b555b3395018c1efdad5b4c5a4afbc2748e1f8c3a34da787ec","tags":[["l","bitcoin"]]}]"#; + assert_eq!(test, expected); } #[test] @@ -502,9 +677,8 @@ mod tests { #[test] fn timestamp_test() { let note = get_note(); - let hash_correct = *b"1686880020"; let ts = note.timestamp_bytes(); - assert_eq!(ts, hash_correct); + assert_eq!(ts, String::<10>::from("1686880020")); } #[test] @@ -528,7 +702,90 @@ mod tests { fn serialize_to_relay_test() { let output = br#"["EVENT",{"content":"esptest","created_at":1686880020,"id":"b515da91ac5df638fae0a6e658e03acc1dda6152dd2107d02d5702ccfcf927e8","kind":1,"pubkey":"098ef66bce60dd4cf10b4ae5949d1ec6dd777ddeb4bc49b47f97275a127a63cf","sig":"89a4f1ad4b65371e6c3167ea8cb13e73cf64dd5ee71224b1edd8c32ad817af2312202cadb2f22f35d599793e8b1c66b3979d4030f1e7a252098da4a4e0c48fab","tags":[]}]"#; let note = get_note(); - let msg = note.serialize_to_relay(); + let msg = note.serialize_to_relay(ClientMsgKinds::Event); assert_eq!(&msg, output); } + + #[test] + fn test_from_json() { + let json = r#"{"content":"esptest","created_at":1686880020,"id":"b515da91ac5df638fae0a6e658e03acc1dda6152dd2107d02d5702ccfcf927e8","kind":1,"pubkey":"098ef66bce60dd4cf10b4ae5949d1ec6dd777ddeb4bc49b47f97275a127a63cf","sig":"89a4f1ad4b65371e6c3167ea8cb13e73cf64dd5ee71224b1edd8c32ad817af2312202cadb2f22f35d599793e8b1c66b3979d4030f1e7a252098da4a4e0c48fab","tags":[]"#; + let note = Note::try_from(json).expect("infallible"); + let expected_note = get_note(); + assert_eq!(note, expected_note); + } + + #[test] + fn test_tags() { + let dm_rcv: &str = r#"{"content":"sZhES/uuV1uMmt9neb6OQw6mykdLYerAnTN+LodleSI=?iv=eM0mGFqFhxmmMwE4YPsQMQ==","created_at":1691110186,"id":"517a5f0f29f5037d763bbd5fbe96c9082c1d39eca917aa22b514c5effc36bab9","kind":4,"pubkey":"ed984a5438492bdc75860aad15a59f8e2f858792824d615401fb49d79c2087b0","sig":"3097de7d5070b892b81b245a5b276eccd7cb283a29a934a71af4960188e55e87d639b774cc331eb9f94ea7c46373c52b8ab39bfee75fe4bb11a1dd4c187e1f3e","tags":[["p","098ef66bce60dd4cf10b4ae5949d1ec6dd777ddeb4bc49b47f97275a127a63cf"]]}"#; + let note = Note::try_from(dm_rcv).unwrap(); + + let tags = note.get_tag("p").unwrap(); + let pubkey = tags.first().unwrap().first().unwrap(); + assert_eq!( + *pubkey, + "098ef66bce60dd4cf10b4ae5949d1ec6dd777ddeb4bc49b47f97275a127a63cf" + ); + } + + #[test] + fn test_get_tag() { + let mut tags = Vec::new(); + tags.push(String::from("p,test_pubkey")).unwrap(); + let note = Note { + id: [0; 64], + pubkey: [0; 64], + created_at: 0, + kind: NoteKinds::DM, + tags, + content: None, + sig: [0; 128], + }; + let tags = note.get_tag("p").unwrap(); + let pubkey = tags.first().unwrap().first().unwrap(); + assert_eq!(*pubkey, "test_pubkey"); + } + + #[test] + fn test_get_two_tags() { + let mut tags = Vec::new(); + tags.push(String::from("l,labeled,another label")).unwrap(); + tags.push(String::from("l,ignore the other label")).unwrap(); + let note = Note { + id: [0; 64], + pubkey: [0; 64], + created_at: 0, + kind: NoteKinds::DM, + tags, + content: None, + sig: [0; 128], + }; + let binding = note.get_tag("l").unwrap(); + let mut tags = binding.iter(); + let label = tags.next().unwrap(); + let mut labels = label.iter(); + assert_eq!(*labels.next().unwrap(), "labeled"); + assert_eq!(*labels.next().unwrap(), "another label"); + + let label = tags.next().unwrap(); + let mut labels = label.iter(); + assert_eq!(*labels.next().unwrap(), "ignore the other label"); + } + + #[test] + fn test_auth_msg() { + let note = Note::new_builder(PRIVKEY) + .unwrap() + .create_auth( + &AuthMessage { + challenge_string: "challenge_me".into(), + }, + "wss://relay.damus.io", + ) + .unwrap() + .build(1691712199, [0; 32]) + .unwrap(); + + let expected = br#"{"content":"","created_at":1691712199,"id":"762b497576a41636c41eb5c74c0eb80894ecb2444c3e5117da0d00d9870d914a","kind":22242,"pubkey":"098ef66bce60dd4cf10b4ae5949d1ec6dd777ddeb4bc49b47f97275a127a63cf","sig":"afb892c683222936537ac1ea1ecdade47adf572e96773dfc6ca021d929d3485ecd7d086b14503e545312f61bd8ffdbd48887cd27b3ab2e4f70aab62a4a1afd1b","tags":[["challenge","challenge_me"],["relay","wss://relay.damus.io"]]}"#; + assert_eq!(note.to_json(), expected); + } } diff --git a/src/nip04.rs b/src/nip04.rs new file mode 100644 index 0000000..cab77e1 --- /dev/null +++ b/src/nip04.rs @@ -0,0 +1,219 @@ +use core::str::FromStr; + +use aes::cipher::generic_array::GenericArray; +use base64ct::{Base64, Encoding}; +use heapless::{String, Vec}; +use secp256k1::{ecdh, PublicKey, SecretKey, XOnlyPublicKey}; + +// use aes::cipher::block_padding::Pkcs7; +use aes::cipher::{ArrayLength, BlockDecryptMut, BlockEncryptMut, KeyIvInit, Unsigned}; +use aes::Aes256; +use cbc::{Decryptor, Encryptor}; + +type Aes256CbcEnc = Encryptor; +type Aes256CbcDec = Decryptor; + +use crate::errors::Error; +use crate::MAX_DM_SIZE; + +/// heavily copied from rust-nostr + +/// Encrypt +pub fn encrypt( + sk: &SecretKey, + pk: &XOnlyPublicKey, + text: &str, + iv: [u8; 16], +) -> Result, Error> { + let key: [u8; 32] = generate_shared_key(sk, pk)?; + + let mut cipher = Aes256CbcEnc::new(&key.into(), &iv.into()); + let mut ciphertext = [16_u8; MAX_DM_SIZE]; + + // fill cipher text from slices of input + let total_blocks = text.len() / 16 + 1; + + for i in 0..total_blocks { + let end_slice = i * 16 + 16; + let end_slice = if end_slice > text.len() { + text.len() + } else { + end_slice + }; + let mut block = pad_block(&text[i * 16..end_slice].as_bytes(), 16); + cipher.encrypt_block_mut(&mut block); + block.iter().enumerate().for_each(|(j, b)| { + ciphertext[i * 16 + j] = *b; + }); + } + + let encode_this = &ciphertext[0..total_blocks * 16]; + let mut enc_buf = [0u8; MAX_DM_SIZE]; + let encoded = Base64::encode(encode_this, &mut enc_buf).map_err(|_| Error::EncodeError)?; + + let mut enc_buf = [0u8; 32]; + let iv_str = Base64::encode(&iv, &mut enc_buf).map_err(|_| Error::EncodeError)?; + + let mut output = String::from_str(&encoded).map_err(|_| Error::ContentOverflow)?; + output + .push_str("?iv=") + .map_err(|_| Error::ContentOverflow)?; + output + .push_str(&iv_str) + .map_err(|_| Error::ContentOverflow)?; + Ok(output) +} + +fn pad_block(input: &[u8], block_size: usize) -> GenericArray +where + B: ArrayLength + Unsigned, +{ + let input_len = input.len(); + let padding_len = block_size - input_len; + let padding_byte = padding_len as u8; + + let mut padded_input = GenericArray::default(); + padded_input[..input_len].copy_from_slice(input); + + for i in input_len..(input_len + padding_len) { + padded_input[i] = padding_byte; + } + + padded_input +} + +/// Dectypt +pub fn decrypt( + sk: &SecretKey, + pk: &XOnlyPublicKey, + encrypted_content: &str, +) -> Result, Error> { + let parsed_content: Vec<&str, 2> = encrypted_content.split("?iv=").collect(); + if parsed_content.len() != 2 { + return Err(Error::MalformedContent); + } + + let mut decrypted_buf = [0_u8; MAX_DM_SIZE]; + + let encrypted_content = + Base64::decode(parsed_content[0], &mut decrypted_buf).map_err(|_| Error::EncodeError)?; + + let mut decrypted_iv = [0_u8; MAX_DM_SIZE]; + let iv = + Base64::decode(parsed_content[1], &mut decrypted_iv).map_err(|_| Error::EncodeError)?; + let key: [u8; 32] = generate_shared_key(sk, pk)?; + + let mut cipher = Aes256CbcDec::new(&key.into(), iv.into()); + let mut ciphertext = [16_u8; MAX_DM_SIZE]; + + // fill cipher text from slices of input + let total_blocks = encrypted_content.len() / 16; + + for i in 0..total_blocks { + // check if on last block, look at last characters of block to calculate padding. save for later? + let end_slice = i * 16 + 16; + let mut block = pad_block(&encrypted_content[i * 16..end_slice], 16); + cipher.decrypt_block_mut(&mut block); + block.iter().enumerate().for_each(|(j, b)| { + ciphertext[i * 16 + j] = *b; + }); + } + let utf_8 = &ciphertext[0..total_blocks * 16]; + let pad_digit = *utf_8.last().ok_or(Error::InternalError)? as usize; + let pad_digit = if pad_digit < 17 { pad_digit } else { 0 }; + // println!("{:?}", utf_8); + // println!("{pad_digit}"); + let utf_8 = &ciphertext[0..utf_8.len() - pad_digit]; + + let mut output = String::new(); + + utf_8.iter().try_for_each(|b| { + output + .push(*b as char) + .map_err(|_| Error::ContentOverflow)?; + Ok(()) + })?; + + Ok(output) +} + +/// Generate shared key +fn generate_shared_key(sk: &SecretKey, pk: &XOnlyPublicKey) -> Result<[u8; 32], Error> { + let pk_normalized: PublicKey = normalize_schnorr_pk(pk)?; + let ssp = ecdh::shared_secret_point(&pk_normalized, sk); + let mut shared_key: [u8; 32] = [0u8; 32]; + shared_key.copy_from_slice(&ssp[..32]); + Ok(shared_key) +} + +/// Normalize Schnorr public key +fn normalize_schnorr_pk(schnorr_pk: &XOnlyPublicKey) -> Result { + let mut pk: String<66> = String::from("02"); + let mut bytes = [0_u8; 64]; + base16ct::lower::encode(&schnorr_pk.serialize(), &mut bytes).map_err(|_| Error::EncodeError)?; + bytes.iter().try_for_each(|b| { + let c = char::from_u32(*b as u32).ok_or(Error::InternalError)?; + pk.push(c).map_err(|_| Error::InternalError)?; + Ok(()) + })?; + Ok(PublicKey::from_str(&pk).map_err(|_| Error::InternalPubkeyError)?) +} + +#[cfg(test)] +mod tests { + use secp256k1::{ffi::types::AlignedType, KeyPair}; + + use crate::Note; + + use super::*; + const _DM_RECV: &str = r#"{"content":"sZhES/uuV1uMmt9neb6OQw6mykdLYerAnTN+LodleSI=?iv=eM0mGFqFhxmmMwE4YPsQMQ==","created_at":1691110186,"id":"517a5f0f29f5037d763bbd5fbe96c9082c1d39eca917aa22b514c5effc36bab9","kind":4,"pubkey":"ed984a5438492bdc75860aad15a59f8e2f858792824d615401fb49d79c2087b0","sig":"3097de7d5070b892b81b245a5b276eccd7cb283a29a934a71af4960188e55e87d639b774cc331eb9f94ea7c46373c52b8ab39bfee75fe4bb11a1dd4c187e1f3e","tags":[["p","098ef66bce60dd4cf10b4ae5949d1ec6dd777ddeb4bc49b47f97275a127a63cf"]]}"#; + const _DM_SEND: &str = r#"{"content":"lPQ9iBd6abUrDBJbHWaL3qqhqsuAxK0aU80IgsZ2aqE=?iv=O1zZfD9HPiig1yuZEWX7uQ==","created_at":1691117390,"id":"c0be8c32d95f7599ccfe324711ad50890ee08985710997fcda1a1a3840a23d51","kind":4,"pubkey":"098ef66bce60dd4cf10b4ae5949d1ec6dd777ddeb4bc49b47f97275a127a63cf","sig":"8ee1e83ab037c9e9ff1ac97db88aa045b2f1d9204daa7fee25e5f42274ee8d5f4365b87677c4f27827ca043becc65c1f38f646d05adf3d2c570b66fea57e5918","tags":[["p","ed984a5438492bdc75860aad15a59f8e2f858792824d615401fb49d79c2087b0"]]}"#; + const FROM_SKEY: &str = "aecb67d55da9b658cd419013d7026f30ee23c5c5b032948e84e8ae523b559f92"; + const MY_SKEY: &str = "a5084b35a58e3e1a26f5efb46cb9dbada73191526aa6d11bccb590cbeb2d8fa3"; + const EXPCTD_MSG: &str = "hello from the internet"; + + #[test] + fn test_e2e() { + let mut buf = [AlignedType::zeroed(); 64]; + let sig_obj = secp256k1::Secp256k1::preallocated_new(&mut buf).expect("test"); + let key_pair = KeyPair::from_seckey_str(&sig_obj, FROM_SKEY).expect("test"); + let pk = key_pair.x_only_public_key().0; + + let my_sk = SecretKey::from_str(MY_SKEY).expect("test"); + let encrypted = encrypt(&my_sk, &pk, EXPCTD_MSG, [0; 16]).expect("test"); + + let decrypted = decrypt( + &key_pair.secret_key(), + &my_sk.x_only_public_key(&sig_obj).0, + encrypted.as_str(), + ) + .expect("test"); + assert_eq!(decrypted, "hello from the internet"); + } + + #[test] + fn test_decrypt() { + let mut buf = [AlignedType::zeroed(); 64]; + let sig_obj = secp256k1::Secp256k1::preallocated_new(&mut buf).expect("test"); + let key_pair = KeyPair::from_seckey_str(&sig_obj, FROM_SKEY).expect("test"); + + let my_sk = SecretKey::from_str(MY_SKEY).expect("test"); + + let decrypted = decrypt( + &key_pair.secret_key(), + &my_sk.x_only_public_key(&sig_obj).0, + "sZhES/uuV1uMmt9neb6OQw6mykdLYerAnTN+LodleSI=?iv=eM0mGFqFhxmmMwE4YPsQMQ==", + ) + .expect("test"); + assert_eq!(decrypted, "hello from the internet"); + } + + #[test] + fn test_rcvd_dm() { + let note = Note::try_from(_DM_SEND).unwrap(); + let msg = note + .read_dm("a5084b35a58e3e1a26f5efb46cb9dbada73191526aa6d11bccb590cbeb2d8fa3") + .unwrap(); + assert_eq!(msg, String::<400>::from("hello from the internet")); + } +} diff --git a/src/parse_json.rs b/src/parse_json.rs new file mode 100644 index 0000000..9130941 --- /dev/null +++ b/src/parse_json.rs @@ -0,0 +1,211 @@ +use heapless::{String, Vec}; + +use crate::{errors, Note}; + +fn get_end_index( + locs: &Vec, + this_pos: usize, + max_len: usize, + is_string: bool, +) -> usize { + if this_pos == locs.len() - 1 { + max_len - if is_string { 2 } else { 1 } + } else { + locs[this_pos + 1] - if is_string { 2 } else { 1 } + } +} + +fn find_index(locs: &Vec, search_element: usize) -> usize { + // can't fail because locs is filled with all search_elements + locs.binary_search(&search_element).expect("infallible") +} + +fn remove_whitespace(value: &str) -> Result, errors::Error> { + let mut output = String::new(); + let space_char = char::from(32_u8); + let quote_char = char::from(34_u8); + // keep track of when we are between quotes + // remove whitespace when we are not between quotes + let mut remove_whitespace = true; + value.chars().try_for_each(|c| { + if c == quote_char { + remove_whitespace = !remove_whitespace; + }; + if c == space_char && !remove_whitespace { + output.push(c).map_err(|_| errors::Error::ContentOverflow)? + } else if c != space_char { + output.push(c).map_err(|_| errors::Error::ContentOverflow)? + } + Ok(()) + })?; + Ok(output) +} + +fn remove_array_chars(value: &str) -> Result, errors::Error> { + let mut output = String::new(); + let left_char = char::from(91_u8); + let right_char = char::from(93_u8); + let quote_char = char::from(34_u8); + value.chars().try_for_each(|c| { + if c != left_char && c != right_char && c != quote_char { + output.push(c).map_err(|_| errors::Error::ContentOverflow)? + } + Ok(()) + })?; + Ok(output) +} + +impl TryFrom<&str> for Note { + type Error = errors::Error; + fn try_from(value: &str) -> Result { + let value: String<1000> = remove_whitespace(value)?; + // set up each var we will search for, including the leading " character for strings + let content_str = r#""content":""#; + let created_at_str = r#""created_at":"#; + let kind_str = r#""kind":"#; + let id_str = r#""id":""#; + let pubkey_str = r#""pubkey":""#; + let sig_str = r#""sig":""#; + let tags_str = r#""tags":"#; + + // find indices matching start locations for each key + let (content_loc, _) = if let Some(val) = value.match_indices(content_str).next() { + val + } else { + return Err(errors::Error::EventMissingField); + }; + let (created_at_loc, _) = if let Some(val) = value.match_indices(created_at_str).next() { + val + } else { + return Err(errors::Error::EventMissingField); + }; + let (kind_loc, _) = if let Some(val) = value.match_indices(kind_str).next() { + val + } else { + return Err(errors::Error::EventMissingField); + }; + let (id_loc, _) = if let Some(val) = value.match_indices(id_str).next() { + val + } else { + return Err(errors::Error::EventMissingField); + }; + let (pubkey_loc, _) = if let Some(val) = value.match_indices(pubkey_str).next() { + val + } else { + return Err(errors::Error::EventMissingField); + }; + let (sig_loc, _) = if let Some(val) = value.match_indices(sig_str).next() { + val + } else { + return Err(errors::Error::EventMissingField); + }; + let (tags_loc, _) = if let Some(val) = value.match_indices(tags_str).next() { + val + } else { + return Err(errors::Error::EventMissingField); + }; + + // sort order of occurences of variables + let mut locs: Vec = Vec::new(); + locs.push(content_loc).expect("infallible"); + locs.push(created_at_loc).expect("infallible"); + locs.push(kind_loc).expect("infallible"); + locs.push(id_loc).expect("infallible"); + locs.push(pubkey_loc).expect("infallible"); + locs.push(sig_loc).expect("infallible"); + locs.push(tags_loc).expect("infallible"); + locs.sort_unstable(); + + // get content data + let content_order_pos = find_index(&locs, content_loc); + let content_start = content_loc + content_str.len(); + let content_end_index = get_end_index(&locs, content_order_pos, value.len(), true); + let content_data = &value[content_start..content_end_index]; + let content = if content_data.len() > 0 { + Some(content_data.into()) + } else { + None + }; + + // get id data + let id_order_pos = find_index(&locs, id_loc); + let id_start = id_loc + id_str.len(); + let id_end_index = get_end_index(&locs, id_order_pos, value.len(), true); + let id_data = &value[id_start..id_end_index]; + let mut id = [0; 64]; + let mut count = 0; + id_data.as_bytes().iter().for_each(|b| { + id[count] = *b; + count += 1; + }); + + // get pubkey data + let pubkey_order_pos = find_index(&locs, pubkey_loc); + let pubkey_start = pubkey_loc + pubkey_str.len(); + let pubkey_end_index = get_end_index(&locs, pubkey_order_pos, value.len(), true); + let pubkey_data = &value[pubkey_start..pubkey_end_index]; + let mut pubkey = [0; 64]; + count = 0; + pubkey_data.as_bytes().iter().for_each(|b| { + pubkey[count] = *b; + count += 1; + }); + + // get sig data + let sig_order_pos = find_index(&locs, sig_loc); + let sig_start = sig_loc + sig_str.len(); + let sig_end_index = get_end_index(&locs, sig_order_pos, value.len(), true); + let sig_data = &value[sig_start..sig_end_index]; + let mut sig = [0; 128]; + count = 0; + sig_data.as_bytes().iter().for_each(|b| { + sig[count] = *b; + count += 1; + }); + + // get kind data + let kind_order_pos = find_index(&locs, kind_loc); + let kind_start = kind_loc + kind_str.len(); + let kind_end_index = get_end_index(&locs, kind_order_pos, value.len(), false); + let kind_data = &value[kind_start..kind_end_index]; + let kind = + u16::from_str_radix(kind_data, 10).map_err(|_| errors::Error::MalformedContent)?; + + // get created_at data + let created_at_order_pos = find_index(&locs, created_at_loc); + let created_at_start = created_at_loc + created_at_str.len(); + let created_at_end_index = get_end_index(&locs, created_at_order_pos, value.len(), false); + let created_at_data = &value[created_at_start..created_at_end_index]; + let created_at = u32::from_str_radix(created_at_data, 10) + .map_err(|_| errors::Error::MalformedContent)?; + + // get tags + let mut tags = Vec::new(); + let tags_order_pos = find_index(&locs, tags_loc); + let tags_start = tags_loc + tags_str.len(); + let tags_end_index = get_end_index(&locs, tags_order_pos, value.len(), true); + let tags_data = &value[tags_start..tags_end_index]; + // splits tags for full array + tags_data.split("],").try_for_each(|tag| { + if tag.len() > 0 { + let tag = remove_array_chars(tag)?; + if let Err(_) = tags.push(tag) { + return Err(errors::Error::TooManyTags); + } + } + Ok(()) + })?; + + // todo: need to add signature verification + + Ok(Note { + id, + pubkey, + created_at, + kind: kind.into(), + tags, + content, + sig, + }) + } +} diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..1b98063 --- /dev/null +++ b/src/query.rs @@ -0,0 +1,364 @@ +//! Build queries to get events from relays +//! +//! - where `subscription_id` is an arbitrary, non-empty string of max length 64 chars +//! +//! # Example +//! ``` +//! use nostr_nostd::query::Query; +//! let mut query = Query::new(); +//! query +//! .authors +//! .push(*b"098ef66bce60dd4cf10b4ae5949d1ec6dd777ddeb4bc49b47f97275a127a63cf") +//! .unwrap(); +//! let msg = query.serialize_to_relay("test_subscription_1".into()).unwrap(); +//! // can send msg to relay, and event will be returned as a list of: ["EVENT","test_subscription_1",{event_1_json}],etc... +//! ``` + +use heapless::{String, Vec}; +use secp256k1::{ffi::types::AlignedType, KeyPair}; + +use crate::{errors, utils::to_decimal_str, NoteKinds}; + +const QUERY_VEC_LEN: usize = 5; +pub struct Query { + /// a list of event ids or prefixes + pub ids: Vec<[u8; 64], QUERY_VEC_LEN>, + /// a list of pubkeys or prefixes, the pubkey of an event must be one of these + pub authors: Vec<[u8; 64], QUERY_VEC_LEN>, + /// a list of a kind numbers + pub kinds: Vec, + /// a list of event ids that are referenced in an "e" tag + pub ref_events: Vec<[u8; 64], QUERY_VEC_LEN>, + /// a list of pubkeys that are referenced in a "p" tag + pub ref_pks: Vec<[u8; 64], QUERY_VEC_LEN>, + /// an integer unix timestamp in seconds, events must be newer than this to pass + pub since: Option, + /// an integer unix timestamp in seconds, events must be older than this to pass + pub until: Option, + /// maximum number of events to be returned in the initial query + pub limit: Option, +} + +impl Query { + /// Creates a new query with all fields initialized empty + #[inline] + pub fn new() -> Self { + Query { + ids: Vec::new(), + authors: Vec::new(), + kinds: Vec::new(), + ref_events: Vec::new(), + ref_pks: Vec::new(), + since: None, + until: None, + limit: None, + } + } + + /// Sets #p tag and kind tag to search for NIP04 messages + #[inline] + pub fn get_my_dms(&mut self, privkey: &str) -> Result<(), errors::Error> { + let mut buf = [AlignedType::zeroed(); 64]; + let sig_obj = secp256k1::Secp256k1::preallocated_new(&mut buf) + .map_err(|_| errors::Error::Secp256k1Error)?; + let key_pair: KeyPair = KeyPair::from_seckey_str(&sig_obj, privkey) + .map_err(|_| errors::Error::InvalidPrivkey)?; + let pubkey = key_pair.x_only_public_key().0; + let pubkey = &pubkey.serialize(); + let mut msg = [0_u8; 64]; + base16ct::lower::encode(pubkey, &mut msg).map_err(|_| errors::Error::EncodeError)?; + self.ref_pks + .push(msg) + .map_err(|_| errors::Error::QueryBuilderOverflow)?; + self.kinds + .push(NoteKinds::DM) + .map_err(|_| errors::Error::QueryBuilderOverflow)?; + Ok(()) + } + + fn to_json(self) -> Result, errors::Error> { + let mut json = Vec::new(); + let mut remove_inner_list_comma = false; + let mut add_obj_comma = false; + json.push(123).expect("impossible"); // { char + if self.ids.len() > 0 { + br#""id":["#.iter().try_for_each(|b| { + json.push(*b).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + add_obj_comma = true; + } + self.ids.iter().try_for_each(|val| { + // 34 = " char + json.push(34).map_err(|_| errors::Error::ContentOverflow)?; + val.iter().try_for_each(|b| { + json.push(*b).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + json.push(34).map_err(|_| errors::Error::ContentOverflow)?; + remove_inner_list_comma = true; + json.push(44).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + if remove_inner_list_comma { + json.pop(); + json.push(93).map_err(|_| errors::Error::ContentOverflow)?; + remove_inner_list_comma = false; + } + if self.authors.len() > 0 { + if add_obj_comma { + json.push(44).map_err(|_| errors::Error::ContentOverflow)?; + } + br#""authors":["#.iter().try_for_each(|b| { + json.push(*b).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + add_obj_comma = true; + } + self.authors.iter().try_for_each(|val| { + // 34 = " char + json.push(34).map_err(|_| errors::Error::ContentOverflow)?; + val.iter().try_for_each(|b| { + json.push(*b).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + json.push(34).map_err(|_| errors::Error::ContentOverflow)?; + remove_inner_list_comma = true; + json.push(44).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + if remove_inner_list_comma { + json.pop(); + json.push(93).map_err(|_| errors::Error::ContentOverflow)?; + remove_inner_list_comma = false; + } + if self.ref_pks.len() > 0 { + if add_obj_comma { + json.push(44).map_err(|_| errors::Error::ContentOverflow)?; + } + br##""#p":["##.iter().try_for_each(|b| { + json.push(*b).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + add_obj_comma = true; + } + self.ref_pks.iter().try_for_each(|val| { + // 34 = " char + json.push(34).map_err(|_| errors::Error::ContentOverflow)?; + val.iter().try_for_each(|b| { + json.push(*b).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + json.push(34).map_err(|_| errors::Error::ContentOverflow)?; + remove_inner_list_comma = true; + json.push(44).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + if remove_inner_list_comma { + json.pop(); + json.push(93).map_err(|_| errors::Error::ContentOverflow)?; + remove_inner_list_comma = false; + } + if self.ref_events.len() > 0 { + if add_obj_comma { + json.push(44).map_err(|_| errors::Error::ContentOverflow)?; + } + br##""#e":["##.iter().try_for_each(|b| { + json.push(*b).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + add_obj_comma = true; + } + self.ref_events.iter().try_for_each(|val| { + // 34 = " char + json.push(34).map_err(|_| errors::Error::ContentOverflow)?; + val.iter().try_for_each(|b| { + json.push(*b).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + remove_inner_list_comma = true; + json.push(44).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + if remove_inner_list_comma { + json.pop(); + json.push(93).map_err(|_| errors::Error::ContentOverflow)?; + remove_inner_list_comma = false; + } + if self.kinds.len() > 0 { + if add_obj_comma { + json.push(44).map_err(|_| errors::Error::ContentOverflow)?; + } + br#""kinds":["#.iter().try_for_each(|b| { + json.push(*b).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + add_obj_comma = true; + } + self.kinds.iter().try_for_each(|kind| { + kind.serialize().chars().try_for_each(|b| { + json.push(b as u8) + .map_err(|_| errors::Error::QueryBuilderOverflow)?; + Ok(()) + })?; + remove_inner_list_comma = true; + json.push(44).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + if remove_inner_list_comma { + json.pop(); + json.push(93).map_err(|_| errors::Error::ContentOverflow)?; + // remove_inner_list_comma = false; + } + + if let Some(since) = self.since { + if add_obj_comma { + json.push(44).map_err(|_| errors::Error::ContentOverflow)?; + } + // add since + br#""since":"#.iter().try_for_each(|b| { + json.push(*b).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + add_obj_comma = true; + to_decimal_str(since).chars().try_for_each(|val| { + json.push(val as u8) + .map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + } + if let Some(until) = self.until { + // add until + if add_obj_comma { + json.push(44).map_err(|_| errors::Error::ContentOverflow)?; + } + br#""until":"#.iter().try_for_each(|b| { + json.push(*b).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + add_obj_comma = true; + to_decimal_str(until).chars().try_for_each(|val| { + json.push(val as u8) + .map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + } + + if let Some(limit) = self.limit { + // add limit + if add_obj_comma { + json.push(44).map_err(|_| errors::Error::ContentOverflow)?; + } + br#""limit":"#.iter().try_for_each(|b| { + json.push(*b).map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + // add_obj_comma = true; + to_decimal_str(limit).chars().try_for_each(|val| { + json.push(val as u8) + .map_err(|_| errors::Error::ContentOverflow)?; + Ok(()) + })?; + } + + json.push(125).expect("impossible"); // } char + Ok(json) + } + + /// Serializes the note for sending to relay. + /// Can error if too many tags/ids/events/etc have been supplied. + /// `subscription_id` will be included with returned events from relay + #[inline] + pub fn serialize_to_relay( + self, + subscription_id: String<64>, + ) -> Result, errors::Error> { + let mut output: Vec = Vec::new(); + // fill in output + r#"["REQ",""#.as_bytes().iter().try_for_each(|bs| { + output + .push(*bs) + .map_err(|_| errors::Error::QueryBuilderOverflow)?; + Ok(()) + })?; + subscription_id + .chars() + .for_each(|c| output.push(c as u8).expect("impossible")); + // append ", to subscription id + output.push(34).expect("impossible"); + output.push(44).expect("impossible"); + let json = self.to_json()?; + json.iter().try_for_each(|bs| { + output + .push(*bs) + .map_err(|_| errors::Error::QueryBuilderOverflow)?; + Ok(()) + })?; + output + .push(93) + .map_err(|_| errors::Error::QueryBuilderOverflow)?; + Ok(output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + const PRIVKEY: &str = "a5084b35a58e3e1a26f5efb46cb9dbada73191526aa6d11bccb590cbeb2d8fa3"; + + #[test] + fn test_dms() { + let mut query = Query::new(); + query + .get_my_dms(PRIVKEY) + .map_err(|_| errors::Error::ContentOverflow) + .expect("test"); + let query = query + .serialize_to_relay("my_dms".into()) + .map_err(|_| errors::Error::ContentOverflow) + .expect("test"); + let expected = br##"["REQ","my_dms",{"#p":["098ef66bce60dd4cf10b4ae5949d1ec6dd777ddeb4bc49b47f97275a127a63cf"],"kinds":[4]}]"##; + assert_eq!(query, expected); + } + + #[test] + fn test_multiple() { + let mut query = Query { + ids: Vec::new(), + authors: Vec::new(), + kinds: Vec::new(), + ref_events: Vec::new(), + ref_pks: Vec::new(), + since: Some(10_000), + until: Some(10_001), + limit: Some(10), + }; + query + .ref_pks + .push([97; 64]) + .map_err(|_| errors::Error::ContentOverflow) + .expect("test"); + query + .ref_pks + .push([98; 64]) + .map_err(|_| errors::Error::ContentOverflow) + .expect("test"); + query + .kinds + .push(NoteKinds::IOT) + .map_err(|_| errors::Error::ContentOverflow) + .expect("test"); + query + .kinds + .push(NoteKinds::Regular(1005)) + .map_err(|_| errors::Error::ContentOverflow) + .expect("test"); + + let query = query + .serialize_to_relay("subscription_1".into()) + .map_err(|_| errors::Error::ContentOverflow) + .expect("test"); + let expected = br##"["REQ","subscription_1",{"#p":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],"kinds":[5732,1005],"since":10000,"until":10001,"limit":10}]"##; + assert_eq!(query, expected); + } +} diff --git a/src/relay_responses.rs b/src/relay_responses.rs index b01972d..80fb197 100644 --- a/src/relay_responses.rs +++ b/src/relay_responses.rs @@ -1,6 +1,35 @@ +//! Handle messages from relays +//! +//! # Example +//! ``` +//! use nostr_nostd::{Note, String, ClientMsgKinds,relay_responses}; +//! use nostr_nostd::relay_responses::{AuthMessage, ResponseTypes}; +//! let privkey = "a5084b35a58e3e1a26f5efb46cb9dbada73191526aa6d11bccb590cbeb2d8fa3"; +//! let auth_msg_from_relay: &str = r#"["AUTH","encrypt this"]"#; +//! let msg_type = ResponseTypes::try_from(auth_msg_from_relay).unwrap(); +//! let msg: AuthMessage = match msg_type { +//! ResponseTypes::Auth => AuthMessage::try_from(auth_msg_from_relay).unwrap(), +//! ResponseTypes::Count => panic!("handle other messages here"), +//! ResponseTypes::Eose => panic!("handle other messages here"), +//! ResponseTypes::Event => panic!("handle other messages here"), +//! ResponseTypes::Notice => panic!("handle other messages here"), +//! ResponseTypes::Ok => panic!("handle other messages here"), +//! }; +//! // aux_rand should be generated from a random number generator +//! // required to keep PRIVKEY secure with Schnorr signatures +//! let aux_rand = [0; 32]; +//! let note = Note::new_builder(privkey) +//! .unwrap() +//! .create_auth(&msg, "wss://relay.example.com") +//! .unwrap() +//! .build(1686880020, aux_rand) +//! .unwrap(); +//! let msg = note.serialize_to_relay(ClientMsgKinds::Auth); +//! ``` +//! use heapless::String; -use crate::errors::ResponseErrors; +use crate::{errors::Error, Note}; const CHALLENGE_STRING_SIZE: usize = 64; const AUTH_STR: &str = r#"["AUTH","#; const COUNT_STR: &str = r#"["COUNT","#; @@ -19,40 +48,40 @@ pub enum ResponseTypes { } #[derive(Debug, PartialEq)] -struct AuthMessage { - challenge_string: String, +pub struct AuthMessage { + pub challenge_string: String, } #[derive(Debug, PartialEq)] -struct CountMessage { - subscription_id: String<64>, - count: u16, +pub struct CountMessage { + pub subscription_id: String<64>, + pub count: u16, } #[derive(Debug, PartialEq)] -struct EoseMessage { - subscription_id: String<64>, +pub struct EoseMessage { + pub subscription_id: String<64>, } #[derive(Debug, PartialEq)] -struct EventMessage { - subscription_id: String<64>, - event_json: [u8; 1000], +pub struct EventMessage { + pub subscription_id: String<64>, + pub note: Note, } #[derive(Debug, PartialEq)] -struct NoticeMessage { - message: String<180>, +pub struct NoticeMessage { + pub message: String<180>, } #[derive(Debug, PartialEq)] -struct OkMessage { - event_id: String<64>, - accepted: bool, - info: String<180>, +pub struct OkMessage { + pub event_id: String<64>, + pub accepted: bool, + pub info: String<180>, } impl TryFrom<&str> for ResponseTypes { - type Error = ResponseErrors; + type Error = Error; fn try_from(value: &str) -> Result { if value.starts_with(AUTH_STR) { Ok(Self::Auth) @@ -67,23 +96,23 @@ impl TryFrom<&str> for ResponseTypes { } else if value.starts_with(OK_STR) { Ok(Self::Ok) } else { - Err(ResponseErrors::InvalidType) + Err(Error::InvalidType) } } } impl TryFrom<&str> for AuthMessage { - type Error = ResponseErrors; + type Error = Error; fn try_from(value: &str) -> Result { let msg_type = ResponseTypes::try_from(value)?; if msg_type != ResponseTypes::Auth { - Err(ResponseErrors::TypeNotAccepted) + Err(Error::TypeNotAccepted) } else { let start_index = AUTH_STR.len() + 2; let end_index = value.len() - 2; // Exclude the trailing '"]' if end_index - start_index > CHALLENGE_STRING_SIZE { - return Err(ResponseErrors::ContentOverflow); + return Err(Error::ContentOverflow); }; // Extract the challenge string and create an AuthMessage @@ -96,17 +125,17 @@ impl TryFrom<&str> for AuthMessage { } impl TryFrom<&str> for CountMessage { - type Error = ResponseErrors; + type Error = Error; fn try_from(value: &str) -> Result { let msg_type = ResponseTypes::try_from(value)?; if msg_type != ResponseTypes::Count { - Err(ResponseErrors::TypeNotAccepted) + Err(Error::TypeNotAccepted) } else { let start_index = COUNT_STR.len() + 2; let end_index = start_index + 64; // an id is 64 characters if value.len() < end_index { - return Err(ResponseErrors::ContentOverflow); + return Err(Error::ContentOverflow); } // Extract the challenge string and create an AuthMessage @@ -114,8 +143,7 @@ impl TryFrom<&str> for CountMessage { let start_index = end_index + r#"", {"count": "#.len(); let end_index = value.len() - r#"}]"#.len(); let count_str = &value[start_index..end_index]; - let num = - u16::from_str_radix(count_str, 10).map_err(|_| ResponseErrors::MalformedContent)?; + let num = u16::from_str_radix(count_str, 10).map_err(|_| Error::MalformedContent)?; Ok(CountMessage { subscription_id: id.into(), count: num, @@ -125,17 +153,17 @@ impl TryFrom<&str> for CountMessage { } impl TryFrom<&str> for EoseMessage { - type Error = ResponseErrors; + type Error = Error; fn try_from(value: &str) -> Result { let msg_type = ResponseTypes::try_from(value)?; if msg_type != ResponseTypes::Eose { - Err(ResponseErrors::TypeNotAccepted) + Err(Error::TypeNotAccepted) } else { let start_index = EOSE_STR.len() + 2; let end_index = start_index + 64; // an id is 64 characters if value.len() < end_index { - return Err(ResponseErrors::ContentOverflow); + return Err(Error::ContentOverflow); } // Extract the challenge string and create an AuthMessage @@ -148,36 +176,42 @@ impl TryFrom<&str> for EoseMessage { } impl TryFrom<&str> for EventMessage { - type Error = ResponseErrors; + type Error = Error; fn try_from(value: &str) -> Result { let msg_type = ResponseTypes::try_from(value)?; if msg_type != ResponseTypes::Event { - Err(ResponseErrors::TypeNotAccepted) + Err(Error::TypeNotAccepted) } else { - let start_index = EVENT_STR.len() + 2; - let end_index = start_index + 64; // an id is 64 characters + let start_index = EVENT_STR.len(); + let value = &value[start_index..]; + let subscription = value.split(",").next().ok_or(Error::EventNotValid)?; + let subscription_id: String<64> = subscription[1..subscription.len() - 1].into(); + let end_index = value.len() - 2; if value.len() < end_index { - return Err(ResponseErrors::ContentOverflow); + return Err(Error::ContentOverflow); } - // todo: implement parsing of event. add check for ID, sig - unimplemented!() + let event_json = &value[subscription_id.len()..end_index]; + Ok(EventMessage { + subscription_id, + note: Note::try_from(event_json)?, + }) } } } impl TryFrom<&str> for NoticeMessage { - type Error = ResponseErrors; + type Error = Error; fn try_from(value: &str) -> Result { let msg_type = ResponseTypes::try_from(value)?; if msg_type != ResponseTypes::Notice { - Err(ResponseErrors::TypeNotAccepted) + Err(Error::TypeNotAccepted) } else { let start_index = COUNT_STR.len() + 3; let end_index = value.len() - 2; if value.len() < end_index { - return Err(ResponseErrors::ContentOverflow); + return Err(Error::ContentOverflow); } // Extract the challenge string and create an AuthMessage @@ -190,17 +224,17 @@ impl TryFrom<&str> for NoticeMessage { } impl TryFrom<&str> for OkMessage { - type Error = ResponseErrors; + type Error = Error; fn try_from(value: &str) -> Result { let msg_type = ResponseTypes::try_from(value)?; if msg_type != ResponseTypes::Ok { - Err(ResponseErrors::TypeNotAccepted) + Err(Error::TypeNotAccepted) } else { let start_index = OK_STR.len() + 2; let end_index = start_index + 64; // an id is 64 characters if value.len() < end_index { - return Err(ResponseErrors::ContentOverflow); + return Err(Error::ContentOverflow); } let id = &value[start_index..end_index]; let start_index = end_index + 3; @@ -211,7 +245,7 @@ impl TryFrom<&str> for OkMessage { } else if true_false == "true," { true } else { - return Err(ResponseErrors::MalformedContent); + return Err(Error::MalformedContent); }; let start_index = if accepted { end_index + 2 @@ -220,7 +254,7 @@ impl TryFrom<&str> for OkMessage { }; let end_index = value.len() - 2; if value.len() < end_index { - return Err(ResponseErrors::ContentOverflow); + return Err(Error::ContentOverflow); } let info = &value[start_index..end_index]; Ok(OkMessage { @@ -234,19 +268,23 @@ impl TryFrom<&str> for OkMessage { #[cfg(test)] mod tests { + use heapless::Vec; + + use crate::Note; + use super::*; const AUTH_MSG: &str = r#"["AUTH", "encrypt me"]"#; const COUNT_MSG: &str = r#"["COUNT", "b515da91ac5df638fae0a6e658e03acc1dda6152dd2107d02d5702ccfcf927e8", {"count": 5}]"#; const EOSE_MSG: &str = r#"["EOSE", "b515da91ac5df638fae0a6e658e03acc1dda6152dd2107d02d5702ccfcf927e8"]"#; - const EVENT_MSG: &str = r#"["EVENT", {"content":"esptest","created_at":1686880020,"id":"b515da91ac5df638fae0a6e658e03acc1dda6152dd2107d02d5702ccfcf927e8","kind":1,"pubkey":"098ef66bce60dd4cf10b4ae5949d1ec6dd777ddeb4bc49b47f97275a127a63cf","sig":"89a4f1ad4b65371e6c3167ea8cb13e73cf64dd5ee71224b1edd8c32ad817af2312202cadb2f22f35d599793e8b1c66b3979d4030f1e7a252098da4a4e0c48fab","tags":[]}]"#; + const EVENT_MSG: &str = r#"["EVENT","sub_1", {"content":"esptest","created_at":1686880020,"id":"b515da91ac5df638fae0a6e658e03acc1dda6152dd2107d02d5702ccfcf927e8","kind":1,"pubkey":"098ef66bce60dd4cf10b4ae5949d1ec6dd777ddeb4bc49b47f97275a127a63cf","sig":"89a4f1ad4b65371e6c3167ea8cb13e73cf64dd5ee71224b1edd8c32ad817af2312202cadb2f22f35d599793e8b1c66b3979d4030f1e7a252098da4a4e0c48fab","tags":[]}]"#; const NOTICE_MSG: &str = r#"["NOTICE", "restricted: we can't serve DMs to unauthenticated users, does your client implement NIP-42?"]"#; const OK_MSG: &str = r#"["OK", "b515da91ac5df638fae0a6e658e03acc1dda6152dd2107d02d5702ccfcf927e8", false, "duplicate event"]"#; #[test] fn test_auth() { let auth_type = ResponseTypes::try_from(AUTH_MSG); - let auth_msg = AuthMessage::try_from(AUTH_MSG).unwrap(); + let auth_msg = AuthMessage::try_from(AUTH_MSG).expect("infallible"); let expected_msg = "encrypt me"; let expected_msg = AuthMessage { challenge_string: expected_msg.into(), @@ -257,7 +295,7 @@ mod tests { #[test] fn test_count() { - let msg = CountMessage::try_from(COUNT_MSG).unwrap(); + let msg = CountMessage::try_from(COUNT_MSG).expect("infallible"); let expected_count = CountMessage { subscription_id: "b515da91ac5df638fae0a6e658e03acc1dda6152dd2107d02d5702ccfcf927e8" .into(), @@ -268,7 +306,7 @@ mod tests { #[test] fn test_notice() { - let msg = NoticeMessage::try_from(NOTICE_MSG).unwrap(); + let msg = NoticeMessage::try_from(NOTICE_MSG).expect("infallible"); let expected_notice = NoticeMessage { message: "restricted: we can't serve DMs to unauthenticated users, does your client implement NIP-42?".into() }; @@ -277,7 +315,7 @@ mod tests { #[test] fn test_eose() { - let msg = EoseMessage::try_from(EOSE_MSG).unwrap(); + let msg = EoseMessage::try_from(EOSE_MSG).expect("infallible"); let expected_msg = EoseMessage { subscription_id: "b515da91ac5df638fae0a6e658e03acc1dda6152dd2107d02d5702ccfcf927e8" .into(), @@ -285,9 +323,28 @@ mod tests { assert_eq!(msg, expected_msg); } + #[test] + fn test_event() { + let msg = EventMessage::try_from(EVENT_MSG).expect("infallible"); + let expected_event = Note { + content: Some("esptest".into()), + id: *b"b515da91ac5df638fae0a6e658e03acc1dda6152dd2107d02d5702ccfcf927e8", + pubkey: *b"098ef66bce60dd4cf10b4ae5949d1ec6dd777ddeb4bc49b47f97275a127a63cf", + created_at: 1686880020, + kind: crate::NoteKinds::ShortNote, + tags: Vec::new(), + sig: *b"89a4f1ad4b65371e6c3167ea8cb13e73cf64dd5ee71224b1edd8c32ad817af2312202cadb2f22f35d599793e8b1c66b3979d4030f1e7a252098da4a4e0c48fab", + }; + let event_msg = EventMessage { + subscription_id: "sub_1".into(), + note: expected_event, + }; + assert_eq!(msg, event_msg); + } + #[test] fn test_ok() { - let msg = OkMessage::try_from(OK_MSG).unwrap(); + let msg = OkMessage::try_from(OK_MSG).expect("infallible"); let expected_msg = OkMessage { event_id: "b515da91ac5df638fae0a6e658e03acc1dda6152dd2107d02d5702ccfcf927e8".into(), accepted: false, diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..7873140 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,39 @@ +use heapless::String; + +const DEC_STRING_SIZE: usize = 10; + +/// Panics if number is larger than 7 digits, ie > 9,999,999 +pub fn to_decimal_str(num: u32) -> String { + if num == 0 { + return String::from("0"); + } + let mut serialized: String = String::new(); + let mut n = num; + while n > 0 { + let last_dec = n % 10; + serialized + .push(char::from_digit(last_dec.into(), 10).expect("impossible to fail here")) + .expect("impossible to fail here"); + n /= 10; + } + + let mut output_str: String = String::new(); + // there's probably a better way to do this... + while let Some(digit) = serialized.pop() { + output_str.push(digit).expect("impossible to fail here"); + } + + output_str +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dec_str() { + let num = 1234; + let to_str = to_decimal_str(num); + assert_eq!(to_str.as_str(), "1234"); + } +}