diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index d645970a..395de155 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -13,4 +13,5 @@ jsonschema = "0.17.1" serde = { version = "1.0.193", features = ["derive"] } serde_json = "1.0.108" type-safe-id = { version = "0.2.1", features = ["serde"] } -thiserror = "1.0.50" \ No newline at end of file +thiserror = "1.0.50" +serde_with = "3.4.0" diff --git a/crates/protocol/src/message/close.rs b/crates/protocol/src/message/close.rs new file mode 100644 index 00000000..2a44bff5 --- /dev/null +++ b/crates/protocol/src/message/close.rs @@ -0,0 +1,83 @@ +use ::serde::{Deserialize, Serialize}; +use chrono::Utc; +use serde_with::skip_serializing_none; +use type_safe_id::{DynamicType, TypeSafeId}; + +use super::{Message, MessageError, MessageKind, MessageMetadata}; + +pub struct Close; + +impl Close { + pub fn create( + from: String, + to: String, + exchange_id: TypeSafeId, + reason: Option, + ) -> Result, MessageError> { + let metadata = MessageMetadata { + from, + to, + kind: MessageKind::Close, + id: MessageKind::Close.typesafe_id()?, + exchange_id: exchange_id, + created_at: Utc::now(), + }; + + let data = CloseData { reason }; + + Ok(Message { + metadata, + data, + signature: None, + }) + } +} + +/// A struct representing the data contained within the [`Message`] for a [`Close`]. +/// +/// See [Quote](https://github.com/TBD54566975/tbdex/tree/main/specs/protocol#close) for more +/// information. +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[skip_serializing_none] +#[serde(rename_all = "camelCase")] +pub struct CloseData { + /// an explanation of why the exchange is being closed/completed + reason: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_data::TestData; + + #[test] + fn can_create() { + let close = Close::create( + "did:example:from_1234".to_string(), + "did:example:to_1234".to_string(), + MessageKind::Rfq.typesafe_id().unwrap(), + Some("I don't want to do business with you any more".to_string()), + ) + .expect("failed to create Close"); + + assert_eq!( + close.data.reason, + Some("I don't want to do business with you any more".to_string()) + ); + assert_eq!(close.metadata.id.type_prefix(), "close"); + } + + #[test] + fn can_parse_close_from_json() { + let close = TestData::get_close( + "did:example:from_1234".to_string(), + MessageKind::Rfq + .typesafe_id() + .expect("failed to generate exchange_id"), + ); + let json = serde_json::to_string(&close).expect("failed to serialize Close"); + let parsed_close: Message = + serde_json::from_str(&json).expect("failed to deserialize Close"); + assert_eq!(close, parsed_close); + } +} diff --git a/crates/protocol/src/message.rs b/crates/protocol/src/message/mod.rs similarity index 92% rename from crates/protocol/src/message.rs rename to crates/protocol/src/message/mod.rs index 60f32a87..65d125d5 100644 --- a/crates/protocol/src/message.rs +++ b/crates/protocol/src/message/mod.rs @@ -1,10 +1,15 @@ +pub mod close; +pub mod order; +pub mod order_status; +pub mod quote; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::to_string; use type_safe_id::{DynamicType, TypeSafeId}; /// An enum representing all possible [`Message`] kinds. -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "lowercase")] pub enum MessageKind { Close, @@ -15,7 +20,7 @@ pub enum MessageKind { } /// A struct representing the metadata present on every [`Message`]. -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct MessageMetadata { /// The message's ID @@ -34,7 +39,7 @@ pub struct MessageMetadata { } /// A struct representing the structure and common functionality available to all Messages. -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct Message { /// An object containing fields about the message diff --git a/crates/protocol/src/message/order.rs b/crates/protocol/src/message/order.rs new file mode 100644 index 00000000..527e3836 --- /dev/null +++ b/crates/protocol/src/message/order.rs @@ -0,0 +1,72 @@ +use ::serde::{Deserialize, Serialize}; +use chrono::Utc; +use type_safe_id::{DynamicType, TypeSafeId}; + +use super::{Message, MessageError, MessageKind, MessageMetadata}; + +pub struct Order; + +impl Order { + pub fn create( + from: String, + to: String, + exchange_id: TypeSafeId, + ) -> Result, MessageError> { + let metadata = MessageMetadata { + from, + to, + kind: MessageKind::Order, + id: MessageKind::Order.typesafe_id()?, + exchange_id: exchange_id, + created_at: Utc::now(), + }; + + let data = OrderData ; + + Ok(Message { + metadata, + data, + signature: None, + }) + } +} + +/// A struct representing the data contained within the [`Message`] for an [`Order`]. +/// Currently, [`Order`] contains no data fields. +/// +/// See [Order](https://github.com/TBD54566975/tbdex/tree/main/specs/protocol#order) for more +/// information. +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderData; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_create() { + let order = Order::create( + "did:example:from_1234".to_string(), + "did:example:to_1234".to_string(), + MessageKind::Rfq.typesafe_id().unwrap(), + ) + .expect("failed to create Order"); + + assert_eq!(order.metadata.id.type_prefix(), "order"); + } + + #[test] + fn can_parse_order_from_json() { + let order = Order::create( + "did:example:from_1234".to_string(), + "did:example:to_1234".to_string(), + MessageKind::Rfq.typesafe_id().unwrap(), + ) + .expect("Could not create Order"); + let json: String = serde_json::to_string(&order).expect("failed to serialize Order"); + let parsed_order: Message = + serde_json::from_str(&json).expect("failed to deserialize Order"); + assert_eq!(order, parsed_order); + } +} diff --git a/crates/protocol/src/message/order_status.rs b/crates/protocol/src/message/order_status.rs new file mode 100644 index 00000000..e5c2827f --- /dev/null +++ b/crates/protocol/src/message/order_status.rs @@ -0,0 +1,80 @@ +use ::serde::{Deserialize, Serialize}; +use chrono::Utc; +use type_safe_id::{DynamicType, TypeSafeId}; + +use super::{Message, MessageError, MessageKind, MessageMetadata}; + +pub struct OrderStatus; + +impl OrderStatus { + pub fn create( + from: String, + to: String, + exchange_id: TypeSafeId, + order_status: String, + ) -> Result, MessageError> { + let metadata = MessageMetadata { + from, + to, + kind: MessageKind::OrderStatus, + id: MessageKind::OrderStatus.typesafe_id()?, + exchange_id: exchange_id, + created_at: Utc::now(), + }; + + let data = OrderStatusData { + order_status, + }; + + Ok(Message { + metadata, + data, + signature: None, + }) + } +} + +/// A struct representing the data contained within the [`Message`] for an [`OrderStatus`]. +/// Currently, [`OrderStatus`] contains no data fields. +/// +/// See [OrderStatus](https://github.com/TBD54566975/tbdex/tree/main/specs/protocol#orderstatus) for more +/// information. +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderStatusData { + /// Current status of Order that's being executed + order_status: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_create() { + let order_status: Message = OrderStatus::create( + "did:example:from_1234".to_string(), + "did:example:to_1234".to_string(), + MessageKind::Rfq.typesafe_id().unwrap(), + "COMPLETED".to_string() + ) + .expect("failed to create OrderStatus"); + + assert_eq!(order_status.metadata.id.type_prefix(), "orderstatus"); + } + + #[test] + fn can_parse_order_status_from_json() { + let order_status = OrderStatus::create( + "did:example:from_1234".to_string(), + "did:example:to_1234".to_string(), + MessageKind::Rfq.typesafe_id().unwrap(), + "COMPLETED".to_string() + ) + .expect("Could not create OrderStatus"); + let json: String = serde_json::to_string(&order_status).expect("failed to serialize OrderStatus"); + let parsed_order_status: Message = + serde_json::from_str(&json).expect("failed to deserialize OrderStatus"); + assert_eq!(order_status, parsed_order_status); + } +} diff --git a/crates/protocol/src/message/quote.rs b/crates/protocol/src/message/quote.rs new file mode 100644 index 00000000..8ca193f3 --- /dev/null +++ b/crates/protocol/src/message/quote.rs @@ -0,0 +1,140 @@ +use ::serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; +use serde_with::skip_serializing_none; +use type_safe_id::{DynamicType, TypeSafeId}; + +use super::{Message, MessageError, MessageKind, MessageMetadata}; + +pub struct Quote; + +impl Quote { + pub fn create( + from: String, + to: String, + exchange_id: TypeSafeId, + data: QuoteData, + ) -> Result, MessageError> { + let metadata = MessageMetadata { + from, + to, + kind: MessageKind::Quote, + id: MessageKind::Quote.typesafe_id()?, + exchange_id, + created_at: Utc::now(), + }; + + Ok(Message { + metadata, + data, + signature: None, + }) + } +} + +/// A struct representing the data contained within the [`Message`] for a [`Quote`]. +/// +/// See [Quote](https://github.com/TBD54566975/tbdex/tree/main/specs/protocol#quote) for more +/// information. +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[skip_serializing_none] +#[serde(rename_all = "camelCase")] +pub struct QuoteData { + /// When this quote expires. Expressed as ISO8601 + pub expires_at: DateTime, + /// the amount of payin currency that the PFI will receive + pub payin: QuoteDetails, + /// the amount of payout currency that Alice will receive + pub payout: QuoteDetails, + /// Object that describes how to pay the PFI, and how to get paid by the PFI (e.g. BTC address, payment link) + pub payment_instructions: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[skip_serializing_none] +#[serde(rename_all = "camelCase")] +pub struct QuoteDetails { + /// ISO 3166 currency code string + pub currency_code: String, + /// The amount of currency expressed in the smallest respective unit + pub amount_subunits: String, + /// The amount paid in fees expressed in the smallest respectice unit + pub fee_subunits: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[skip_serializing_none] +#[serde(rename_all = "camelCase")] +pub struct PaymentInstructions { + /// Link or Instruction describing how to pay the PFI. + pub payin: Option, + /// Link or Instruction describing how to get paid by the PFI + pub payout: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[skip_serializing_none] + +#[serde(rename_all = "camelCase")] +pub struct PaymentInstruction { + /// Link or Instruction describing how to pay the PFI. + pub link: Option, + /// Instruction on how Alice can pay PFI, or how Alice can be paid by the PFI + pub instruction: Option, +} + +#[cfg(test)] +mod tests { + use crate::test_data::TestData; + + use super::*; + + #[test] + fn can_create() { + let quote = Quote::create( + "did:example:from_1234".to_string(), + "did:example:to_1234".to_string(), + MessageKind::Rfq.typesafe_id().unwrap(), + QuoteData { + expires_at: Utc::now(), + payin: QuoteDetails { + currency_code: "USD".to_string(), + amount_subunits: "100".to_string(), + fee_subunits: Some("10".to_string()), + }, + payout: QuoteDetails { + currency_code: "BTC".to_string(), + amount_subunits: "2500".to_string(), + fee_subunits: None, + }, + payment_instructions: Some(PaymentInstructions { + payin: Some(PaymentInstruction { + link: Some("example.com/payin".to_string()), + instruction: Some("Hand me the cash".to_string()), + }), + payout: Some(PaymentInstruction { + link: None, + instruction: Some("BOLT 12".to_string()), + }), + }), + }, + ) + .expect("failed to create Quote"); + + assert_eq!(quote.metadata.id.type_prefix(), "quote"); + } + + #[test] + fn can_parse_quote_from_json() { + let quote = TestData::get_quote( + "did:example:from_1234".to_string(), + "did:example:to_1234".to_string(), + MessageKind::Rfq + .typesafe_id() + .expect("failed to generate exchange_id"), + ); + let json = serde_json::to_string("e).expect("failed to serialize Quote"); + let parsed_quote: Message = + serde_json::from_str(&json).expect("failed to deserialize Quote"); + assert_eq!(quote, parsed_quote); + } +} diff --git a/crates/protocol/src/resource/offering.rs b/crates/protocol/src/resource/offering.rs index 2d3787bf..0c9c7b23 100644 --- a/crates/protocol/src/resource/offering.rs +++ b/crates/protocol/src/resource/offering.rs @@ -4,6 +4,7 @@ use credentials::pex::v2::PresentationDefinition; use jsonschema::{Draft, JSONSchema}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; +use serde_with::skip_serializing_none; /// Struct that interacts with an [`Offering`] [`Resource`] pub struct Offering; @@ -55,6 +56,7 @@ pub struct OfferingData { } #[derive(Debug, Default, Deserialize, PartialEq, Serialize)] +#[skip_serializing_none] #[serde(rename_all = "camelCase")] pub struct CurrencyDetails { /// ISO 3166 currency code string @@ -66,6 +68,7 @@ pub struct CurrencyDetails { } #[derive(Debug, Default, Deserialize, PartialEq, Serialize)] +#[skip_serializing_none] #[serde(rename_all = "camelCase")] pub struct PaymentMethod { /// Type of payment method (i.e. `DEBIT_CARD`, `BITCOIN_ADDRESS`, `SQUARE_PAY`) diff --git a/crates/protocol/src/test_data.rs b/crates/protocol/src/test_data.rs index 28ffe4aa..c2c2c94d 100644 --- a/crates/protocol/src/test_data.rs +++ b/crates/protocol/src/test_data.rs @@ -1,7 +1,12 @@ +use crate::message::close::{Close, CloseData}; +use crate::message::quote::{Quote, QuoteData, QuoteDetails, PaymentInstructions, PaymentInstruction}; +use crate::message::Message; use crate::resource::offering::{CurrencyDetails, Offering, OfferingData, PaymentMethod}; use crate::resource::Resource; +use chrono::Utc; use credentials::pex::v2::{Constraints, Field, InputDescriptor, PresentationDefinition}; use serde_json::{json, Value as JsonValue}; +use type_safe_id::{DynamicType, TypeSafeId}; #[cfg(test)] pub struct TestData; @@ -39,6 +44,52 @@ impl TestData { .expect("failed to create offering") } + pub fn get_close(from: String, exchange_id: TypeSafeId) -> Message { + Close::create( + from, + "did:example:to_1234".to_string(), + exchange_id, + Some("I don't want to do business with you anymore".to_string()), + ) + .expect("failed to create Close") + } + + pub fn get_quote( + from: String, + to: String, + exchange_id: TypeSafeId, + ) -> Message { + Quote::create( + from, + to, + exchange_id, + QuoteData { + expires_at: Utc::now(), + payin: QuoteDetails { + currency_code: "USD".to_string(), + amount_subunits: "100".to_string(), + fee_subunits: Some("10".to_string()), + }, + payout: QuoteDetails { + currency_code: "BTC".to_string(), + amount_subunits: "2500".to_string(), + fee_subunits: None, + }, + payment_instructions: Some(PaymentInstructions { + payin: Some(PaymentInstruction { + link: Some("example.com/payin".to_string()), + instruction: Some("Hand me the cash".to_string()), + }), + payout: Some(PaymentInstruction { + link: None, + instruction: Some("BOLT 12".to_string()), + }), + }), + }, + ) + .expect("failed to create Quote") + } + fn get_presentation_definition() -> PresentationDefinition { PresentationDefinition { id: "test-pd-id".to_string(),