diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml index 658b533d4..c75b4eefd 100644 --- a/crates/nostr/Cargo.toml +++ b/crates/nostr/Cargo.toml @@ -20,22 +20,22 @@ default = ["std", "all-nips"] std = [ "dep:once_cell", "cbc?/std", - "base64?/std", - "bitcoin/std", - "bitcoin/rand-std", - "bip39?/std", - "chacha20?/std", + "base64?/std", + "bitcoin/std", + "bitcoin/rand-std", + "bip39?/std", + "chacha20?/std", "negentropy/std", - "serde/std", - "serde_json/std", - "tracing/std", + "serde/std", + "serde_json/std", + "tracing/std", "url-fork/std", ] alloc = [ "cbc?/alloc", "base64?/alloc", "bitcoin/no-std", - "serde/alloc", + "serde/alloc", "serde_json/alloc", ] blocking = ["reqwest?/blocking"] @@ -113,3 +113,7 @@ required-features = ["std"] [[example]] name = "vanity" required-features = ["std"] + +[[example]] +name = "nip15" +required-features = ["std"] diff --git a/crates/nostr/examples/nip15.rs b/crates/nostr/examples/nip15.rs new file mode 100644 index 000000000..d18792f82 --- /dev/null +++ b/crates/nostr/examples/nip15.rs @@ -0,0 +1,25 @@ +use nostr::prelude::*; +const ALICE_SK: &str = "6b911fd37cdf5c81d4c0adb1ab7fa822ed253ab0ad9aa18d77257c88b29b718e"; +fn main() -> Result<()> { + let alice_keys = Keys::from_sk_str(ALICE_SK)?; + let shipping = ShippingMethod::new("123", 5.50).name("DHL"); + + let stall = StallData::new("123", "my test stall", "USD") + .description("this is a test stall") + .shipping(vec![shipping.clone()]); + + let stall_event = EventBuilder::new_stall_data(stall).to_event(&alice_keys)?; + println!("{}", stall_event.as_json()); + + let product = ProductData::new("1", "123", "my test product", "USD") + .description("this is a test product") + .price(5.50) + .shipping(vec![shipping.get_shipping_cost()]) + .images(vec!["https://example.com/image.png".into()]) + .categories(vec!["test".into()]); + + let product_event = EventBuilder::new_product_data(product).to_event(&alice_keys)?; + println!("{}", product_event.as_json()); + + Ok(()) +} diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index b1e834e12..a2d334bf3 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -20,6 +20,7 @@ use super::{Event, EventId, UnsignedEvent}; use crate::key::{self, Keys}; #[cfg(feature = "nip04")] use crate::nips::nip04; +use crate::nips::nip15::{ProductData, StallData}; #[cfg(all(feature = "std", feature = "nip46"))] use crate::nips::nip46::Message as NostrConnectMessage; use crate::nips::nip53::LiveEvent; @@ -880,6 +881,22 @@ impl EventBuilder { let tags: Vec = data.into(); Self::new(Kind::HttpAuth, "", &tags) } + + /// Set stall data + /// + /// + pub fn new_stall_data(data: StallData) -> Self { + let tags: Vec = data.clone().into(); + Self::new(Kind::SetStall, data, &tags) + } + + /// Set product data + /// + /// + pub fn new_product_data(data: ProductData) -> Self { + let tags: Vec = data.clone().into(); + Self::new(Kind::SetProduct, data, &tags) + } } #[cfg(test)] diff --git a/crates/nostr/src/event/kind.rs b/crates/nostr/src/event/kind.rs index 4bb52a099..160b239d0 100644 --- a/crates/nostr/src/event/kind.rs +++ b/crates/nostr/src/event/kind.rs @@ -104,6 +104,10 @@ pub enum Kind { FileMetadata, /// HTTP Auth (NIP98) HttpAuth, + /// Set stall (NIP15) + SetStall, + /// Set product (NIP15) + SetProduct, /// Regular Events (must be between 1000 and <=9999) Regular(u16), /// Replaceable event (must be between 10000 and <20000) @@ -193,6 +197,8 @@ impl From for Kind { 1311 => Self::LiveEventMessage, 30008 => Self::ProfileBadges, 30009 => Self::BadgeDefinition, + 30017 => Self::SetStall, + 30018 => Self::SetProduct, 30023 => Self::LongFormTextNote, 30078 => Self::ApplicationSpecificData, 1063 => Self::FileMetadata, @@ -247,6 +253,8 @@ impl From for u64 { Kind::LiveEventMessage => 1311, Kind::ProfileBadges => 30008, Kind::BadgeDefinition => 30009, + Kind::SetStall => 30017, + Kind::SetProduct => 30018, Kind::LongFormTextNote => 30023, Kind::ApplicationSpecificData => 30078, Kind::FileMetadata => 1063, @@ -327,6 +335,8 @@ mod tests { assert_eq!(Kind::Custom(20100), Kind::Custom(20100)); assert_eq!(Kind::Custom(20100), Kind::Ephemeral(20100)); assert_eq!(Kind::TextNote, Kind::Custom(1)); + assert_eq!(Kind::ParameterizedReplaceable(30017), Kind::SetStall); + assert_eq!(Kind::ParameterizedReplaceable(30018), Kind::SetProduct); } #[test] diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index c1cc90ac7..d3ff2996d 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -14,6 +14,7 @@ pub mod nip06; #[cfg(all(feature = "std", feature = "nip11"))] pub mod nip11; pub mod nip13; +pub mod nip15; pub mod nip19; pub mod nip21; pub mod nip26; diff --git a/crates/nostr/src/nips/nip15.rs b/crates/nostr/src/nips/nip15.rs new file mode 100644 index 000000000..e6f1fc11d --- /dev/null +++ b/crates/nostr/src/nips/nip15.rs @@ -0,0 +1,383 @@ +// Copyright (c) 2023 ProTom +// Distributed under the MIT software license + +//! NIP15 +//! +//! + +use alloc::string::String; +use alloc::vec::Vec; + +use bitcoin::secp256k1::XOnlyPublicKey; + +use crate::Tag; + +/// Payload for creating or updating stall +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StallData { + /// UUID of the stall generated by merchant + pub id: String, + /// Stall name + pub name: String, + /// Stall description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Currency used + pub currency: String, + /// Available shipping methods + pub shipping: Vec, +} + +impl StallData { + /// Create a new stall + pub fn new(id: &str, name: &str, currency: &str) -> Self { + Self { + id: id.into(), + name: name.into(), + description: None, + currency: currency.into(), + shipping: Vec::new(), + } + } + + /// Set the description of the stall + pub fn description(self, description: &str) -> Self { + Self { + description: Some(description.into()), + ..self + } + } + + /// Add a shipping method to the stall + pub fn shipping(self, shipping: Vec) -> Self { + Self { shipping, ..self } + } +} + +impl From for Vec { + fn from(value: StallData) -> Self { + vec![Tag::Identifier(value.id)] + } +} + +impl From for String { + fn from(value: StallData) -> Self { + serde_json::to_string(&value).unwrap_or_default() + } +} + +/// Payload for creating or updating product +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProductData { + /// UUID of the product generated by merchant + pub id: String, + /// Id of the stall that this product belongs to + pub stall_id: String, + /// Product name + pub name: String, + /// Description of the product + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Image urls of the product + #[serde(skip_serializing_if = "Option::is_none")] + pub images: Option>, + /// Currency used + pub currency: String, + /// Price of the product + pub price: f64, + /// Available items + pub quantity: u64, + /// Specifications of the product + #[serde(skip_serializing_if = "Option::is_none")] + pub specs: Option>>, + /// Shipping method costs + pub shipping: Vec, + /// Categories of the product (will be added to tags) + #[serde(skip_serializing)] + pub categories: Option>, +} + +impl ProductData { + /// Create a new product + pub fn new(id: &str, stall_id: &str, name: &str, currency: &str) -> Self { + Self { + id: id.into(), + stall_id: stall_id.into(), + name: name.into(), + description: None, + images: None, + currency: currency.into(), + price: 0.0, + quantity: 1, + specs: None, + shipping: Vec::new(), + categories: None, + } + } + + /// Set the description of the product + pub fn description(self, description: &str) -> Self { + Self { + description: Some(description.into()), + ..self + } + } + + /// Add images to the product + pub fn images(self, images: Vec) -> Self { + Self { + images: Some(images), + ..self + } + } + + /// Set the price of the product + pub fn price(self, price: f64) -> Self { + Self { price, ..self } + } + + /// Set the available quantity of the product + pub fn quantity(self, quantity: u64) -> Self { + Self { quantity, ..self } + } + + /// Set the specifications of the product (e.g. size, color, etc.). Each inner vector should + /// only contain 2 elements, the first being the name of the spec and the second being the value + /// of the spec. + pub fn specs(self, specs: Vec>) -> Self { + let valid = specs.into_iter().filter(|spec| spec.len() == 2).collect(); + Self { + specs: Some(valid), + ..self + } + } + + /// Add a shipping method to the product + pub fn shipping(self, shipping: Vec) -> Self { + Self { shipping, ..self } + } + + /// Add categories to the product + pub fn categories(self, categories: Vec) -> Self { + Self { + categories: Some(categories), + ..self + } + } +} + +impl From for Vec { + fn from(value: ProductData) -> Self { + let mut tags = Vec::new(); + tags.push(Tag::Identifier(value.stall_id)); + value.categories.unwrap_or_default().iter().for_each(|cat| { + tags.push(Tag::Hashtag(cat.into())); + }); + tags + } +} + +impl From for String { + fn from(value: ProductData) -> Self { + serde_json::to_string(&value).unwrap_or_default() + } +} + +/// A shipping method as defined by the merchant +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShippingMethod { + /// Shipping method unique id by merchant + pub id: String, + /// Shipping method name + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Shipping method cost (currency is the same as the stall) + pub cost: f64, + /// Covered regions + pub regions: Vec, +} + +impl ShippingMethod { + /// Create a new shipping method + pub fn new(id: &str, cost: f64) -> Self { + Self { + id: id.into(), + name: None, + cost, + regions: Vec::new(), + } + } + + /// Set the name of the shipping method + pub fn name(self, name: &str) -> Self { + Self { + name: Some(name.into()), + ..self + } + } + + /// Add a region to the shipping method + pub fn regions(self, regions: Vec) -> Self { + Self { regions, ..self } + } + + /// Get the product shipping cost of the shipping method + pub fn get_shipping_cost(self) -> ShippingCost { + ShippingCost { + id: self.id, + cost: self.cost, + } + } +} + +/// Delivery cost for shipping method as defined by the merchant in the product +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShippingCost { + /// Id of the shipping method + pub id: String, + /// Cost to use this shipping method + pub cost: f64, +} + +/// Payload for customer creating an order +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomerOrder { + /// Unique id of the order generated by customer + pub id: String, + /// Message type (0 in case of customer order) + #[serde(rename = "type")] + pub type_: usize, + /// Name of the customer + name: Option, + /// Address of the customer if product is physical + address: Option, + /// Message to the merchant + message: Option, + /// Contact details of the customer + contact: CustomerContact, + /// Items ordered + items: Vec, + /// Shipping method id + shipping_id: String, +} + +/// Payload for a merchant to create a payment request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MerchantPaymentRequest { + /// Unique id of the order generated by customer + pub id: String, + /// Message type (1 in case of merchant payment request) + #[serde(rename = "type")] + pub type_: usize, + /// Available payment options + pub payment_options: Vec, +} + +/// Payload to notify a customer about the received payment and or shipping +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MerchantVerifyPayment { + /// Unique id of the order generated by customer + pub id: String, + /// Type of the message (2 in case of merchant verify payment) + #[serde(rename = "type")] + pub type_: usize, + /// Payment successful + pub paid: bool, + /// Item shipped + pub shipped: bool, +} + +/// A customers contact options +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomerContact { + /// Nostr pub key of the customer (optional, as not decided yet if required) + pub nostr: Option, + /// Phone number of the customer + pub phone: Option, + /// Email of the customer + pub email: Option, +} + +/// An item in the order +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomerOrderItem { + /// Id of the product + pub id: String, + /// Quantity of the product + pub quantity: u64, +} + +/// A payment option of an invoice +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaymentOption { + /// Name of the payment option + #[serde(rename = "type")] + pub type_: String, + /// Payment link (url, ln invoice, etc.) + pub link: String, +} + +#[cfg(test)] +mod tests { + use alloc::string::String; + use alloc::vec::Vec; + + use super::*; + #[test] + fn test_stall_data() { + let stall = StallData::new("123", "Test Stall", "USD") + .description("Test Description") + .shipping(vec![ShippingMethod::new("123", 5.0).name("default")]); + let tags: Vec = stall.clone().into(); + assert_eq!(tags.len(), 1); + assert_eq!( + tags[0], + Tag::Identifier("123".into()), + "tags contains stall id" + ); + + let string: String = stall.into(); + assert_eq!( + string, + r#"{"id":"123","name":"Test Stall","description":"Test Description","currency":"USD","shipping":[{"id":"123","name":"default","cost":5.0,"regions":[]}]}"# + ); + } + + #[test] + fn test_product_data() { + let product = ProductData::new("123", "456", "Test Product", "USD") + .images(vec!["https://example.com/image.png".into()]) + .price(10.0) + .quantity(10) + .specs(vec![vec!["Size".into(), "M".into()]]) + .shipping(vec![ShippingCost { + id: "123".into(), + cost: 5.0, + }]) + .categories(vec!["Test".into(), "Product".into()]); + + let tags: Vec = product.clone().into(); + assert_eq!(tags.len(), 3); + assert_eq!( + tags[0], + Tag::Identifier("456".into()), + "tags contains stall id" + ); + assert_eq!( + tags[1], + Tag::Hashtag("Test".into()), + "tags contains category" + ); + assert_eq!( + tags[2], + Tag::Hashtag("Product".into()), + "tags contains category" + ); + + let string: String = product.into(); + assert_eq!( + string, + r#"{"id":"123","stall_id":"456","name":"Test Product","images":["https://example.com/image.png"],"currency":"USD","price":10.0,"quantity":10,"specs":[["Size","M"]],"shipping":[{"id":"123","cost":5.0}]}"# + ); + } +} diff --git a/crates/nostr/src/prelude.rs b/crates/nostr/src/prelude.rs index 0b8ba5f6b..c76708880 100644 --- a/crates/nostr/src/prelude.rs +++ b/crates/nostr/src/prelude.rs @@ -41,6 +41,7 @@ pub use crate::nips::nip06::{self, *}; #[cfg(all(feature = "std", feature = "nip11"))] pub use crate::nips::nip11::{self, *}; pub use crate::nips::nip13::{self, *}; +pub use crate::nips::nip15::{self, *}; pub use crate::nips::nip19::{self, *}; pub use crate::nips::nip21::{self, *}; pub use crate::nips::nip26::{self, *};