From 5ca618c24aed9fa920e48127ffab7351d1b61c58 Mon Sep 17 00:00:00 2001 From: Silvestrs Timofejevs Date: Tue, 24 Oct 2023 16:35:18 +0100 Subject: [PATCH] feat: add WalletConnect v2 Sign API This change adds WalletConnect v2 Sign API, as per: https://specs.walletconnect.com/2.0/specs/clients/sign/ Please note that although the specification is fairly thorough, there are some inconsistencies. This implementation is derived from specs, analyzing ws traffic in browsers devtools, as well as the original WalletConnect JavaScript client at: https://github.com/WalletConnect/walletconnect-monorepo Design decisions: Modularity: - RPC and crypto modules are not dependent on each other, so client implementations don't have to use their own crypto implementation. Closes: #47 --- Cargo.toml | 7 +- sign_api/Cargo.toml | 26 +++ sign_api/src/crypto.rs | 2 + sign_api/src/crypto/payload.rs | 238 +++++++++++++++++++++ sign_api/src/crypto/session.rs | 73 +++++++ sign_api/src/lib.rs | 3 + sign_api/src/pairing_uri.rs | 164 ++++++++++++++ sign_api/src/rpc.rs | 138 ++++++++++++ sign_api/src/rpc/params.rs | 219 +++++++++++++++++++ sign_api/src/rpc/params/session_delete.rs | 25 +++ sign_api/src/rpc/params/session_event.rs | 32 +++ sign_api/src/rpc/params/session_extend.rs | 24 +++ sign_api/src/rpc/params/session_ping.rs | 22 ++ sign_api/src/rpc/params/session_propose.rs | 40 ++++ sign_api/src/rpc/params/session_request.rs | 32 +++ sign_api/src/rpc/params/session_settle.rs | 57 +++++ sign_api/src/rpc/params/session_update.rs | 24 +++ sign_api/src/rpc/params/shared_types.rs | 141 ++++++++++++ 18 files changed, 1265 insertions(+), 2 deletions(-) create mode 100644 sign_api/Cargo.toml create mode 100644 sign_api/src/crypto.rs create mode 100644 sign_api/src/crypto/payload.rs create mode 100644 sign_api/src/crypto/session.rs create mode 100644 sign_api/src/lib.rs create mode 100644 sign_api/src/pairing_uri.rs create mode 100644 sign_api/src/rpc.rs create mode 100644 sign_api/src/rpc/params.rs create mode 100644 sign_api/src/rpc/params/session_delete.rs create mode 100644 sign_api/src/rpc/params/session_event.rs create mode 100644 sign_api/src/rpc/params/session_extend.rs create mode 100644 sign_api/src/rpc/params/session_ping.rs create mode 100644 sign_api/src/rpc/params/session_propose.rs create mode 100644 sign_api/src/rpc/params/session_request.rs create mode 100644 sign_api/src/rpc/params/session_settle.rs create mode 100644 sign_api/src/rpc/params/session_update.rs create mode 100644 sign_api/src/rpc/params/shared_types.rs diff --git a/Cargo.toml b/Cargo.toml index 8b29593..a70043c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,18 +9,21 @@ license = "Apache-2.0" [workspace] members = [ "relay_client", - "relay_rpc" + "relay_rpc", + "sign_api" ] [features] default = ["full"] -full = ["client", "rpc"] +full = ["client", "rpc", "sign_api"] client = ["dep:relay_client"] rpc = ["dep:relay_rpc"] +sign_api = ["dep:sign_api"] [dependencies] relay_client = { path = "./relay_client", optional = true } relay_rpc = { path = "./relay_rpc", optional = true } +sign_api = { path = "./sign_api", optional = true } [dev-dependencies] anyhow = "1" diff --git a/sign_api/Cargo.toml b/sign_api/Cargo.toml new file mode 100644 index 0000000..95b9004 --- /dev/null +++ b/sign_api/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "sign_api" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1" +base58 = "0.2" +base64 = "0.21" +base64-url = "2.0" +chacha20poly1305 = "0.10" +chrono = "0.4" +hex = "0.4" +hex-literal = "0.4" +hkdf = "0.12" +lazy_static = "1.4" +once_cell = "1.16" +paste = "1.0" +rand = "0.8" +regex = "1.10" +sha2 = "0.10" +serde = { version = "1.0", features = ["derive", "rc"] } +serde_json = "1.0" +thiserror = "1.0" +x25519-dalek = { version = "2.0", features = ["static_secrets"] } +url = "2.4" diff --git a/sign_api/src/crypto.rs b/sign_api/src/crypto.rs new file mode 100644 index 0000000..45b480b --- /dev/null +++ b/sign_api/src/crypto.rs @@ -0,0 +1,2 @@ +pub mod payload; +pub mod session; diff --git a/sign_api/src/crypto/payload.rs b/sign_api/src/crypto/payload.rs new file mode 100644 index 0000000..a77cef3 --- /dev/null +++ b/sign_api/src/crypto/payload.rs @@ -0,0 +1,238 @@ +use { + anyhow::Result, + base64::{prelude::BASE64_STANDARD, Engine}, + chacha20poly1305::{ + aead::{Aead, KeyInit, OsRng, Payload}, + AeadCore, ChaCha20Poly1305, Nonce, + }, +}; + +// https://specs.walletconnect.com/2.0/specs/clients/core/crypto/ +// crypto-envelopes +const TYPE_0: u8 = 0; +const TYPE_1: u8 = 1; +const TYPE_INDEX: usize = 0; +const TYPE_LENGTH: usize = 1; +const IV_LENGTH: usize = 12; +const PUB_KEY_LENGTH: usize = 32; +const SYM_KEY_LENGTH: usize = 32; + +pub type Iv = [u8; IV_LENGTH]; +pub type SymKey = [u8; SYM_KEY_LENGTH]; +pub type PubKey = [u8; PUB_KEY_LENGTH]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EnvelopeType<'a> { + Type0, + Type1 { sender_public_key: &'a PubKey }, +} + +/// Non-owning convenient representation of the decoded payload blob. +#[derive(Clone, Debug, PartialEq, Eq)] +struct EncodingParams<'a> { + /// Encrypted payload. + sealed: &'a [u8], + /// Initialization Vector. + iv: &'a Iv, + envelope_type: EnvelopeType<'a>, +} + +impl<'a> EncodingParams<'a> { + fn parse_decoded(data: &'a [u8]) -> Result { + let envelope_type = data[0]; + match envelope_type { + TYPE_0 => { + let iv_index: usize = TYPE_INDEX + TYPE_LENGTH; + let sealed_index: usize = iv_index + IV_LENGTH; + Ok(EncodingParams { + iv: data[iv_index..=IV_LENGTH].try_into()?, + sealed: &data[sealed_index..], + envelope_type: EnvelopeType::Type0, + }) + } + TYPE_1 => { + let key_index: usize = TYPE_INDEX + TYPE_LENGTH; + let iv_index: usize = key_index + PUB_KEY_LENGTH; + let sealed_index: usize = iv_index + IV_LENGTH; + Ok(EncodingParams { + iv: data[iv_index..=IV_LENGTH].try_into()?, + sealed: &data[sealed_index..], + envelope_type: EnvelopeType::Type1 { + sender_public_key: data[key_index..=PUB_KEY_LENGTH].try_into()?, + }, + }) + } + _ => anyhow::bail!("Invalid envelope type: {}", envelope_type), + } + } +} + +/// Encrypts and encodes the plain-text payload. +/// +/// TODO: RNG as an input +pub fn encrypt_and_encode(envelope_type: EnvelopeType, msg: T, key: &SymKey) -> Result +where + T: AsRef<[u8]>, +{ + let payload = Payload { + msg: msg.as_ref(), + aad: &[], + }; + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); + + let sealed = encrypt(&nonce, payload, key)?; + Ok(encode( + envelope_type, + sealed.as_slice(), + nonce.as_slice().try_into()?, + )) +} + +/// Decodes and decrypts the Type0 envelope payload. +pub fn decode_and_decrypt_type0(msg: T, key: &SymKey) -> Result +where + T: AsRef<[u8]>, +{ + let data = BASE64_STANDARD.decode(msg)?; + let decoded = EncodingParams::parse_decoded(&data)?; + if let EnvelopeType::Type1 { .. } = decoded.envelope_type { + anyhow::bail!("Expected envelope type 0"); + } + + let payload = Payload { + msg: decoded.sealed, + aad: &[], + }; + let decrypted = decrypt(decoded.iv.try_into()?, payload, key)?; + + Ok(String::from_utf8(decrypted)?) +} + +fn encrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result> { + let cipher = ChaCha20Poly1305::new(key.try_into()?); + let sealed = cipher + .encrypt(nonce, payload) + .map_err(|e| anyhow::anyhow!("Encryption failed, err: {:?}", e))?; + + Ok(sealed) +} + +fn encode(envelope_type: EnvelopeType, sealed: &[u8], iv: &Iv) -> String { + match envelope_type { + EnvelopeType::Type0 => BASE64_STANDARD.encode([&[TYPE_0], iv.as_slice(), sealed].concat()), + EnvelopeType::Type1 { sender_public_key } => { + BASE64_STANDARD.encode([&[TYPE_1], sender_public_key.as_slice(), iv, sealed].concat()) + } + } +} + +fn decrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result> { + let cipher = ChaCha20Poly1305::new(key.try_into()?); + let unsealed = cipher + .decrypt(nonce, payload) + .map_err(|e| anyhow::anyhow!("Decryption failed, err: {:?}", e))?; + + Ok(unsealed) +} + +#[cfg(test)] +mod tests { + use hex_literal::hex; + + use super::*; + + // https://www.rfc-editor.org/rfc/rfc7539#section-2.8.2 + // Below constans are taken from this section of the RFC. + + const PLAINTEXT: &str = r#"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."#; + const CIPHERTEXT: [u8; 114] = hex!( + "d3 1a 8d 34 64 8e 60 db 7b 86 af bc 53 ef 7e c2 + a4 ad ed 51 29 6e 08 fe a9 e2 b5 a7 36 ee 62 d6 + 3d be a4 5e 8c a9 67 12 82 fa fb 69 da 92 72 8b + 1a 71 de 0a 9e 06 0b 29 05 d6 a5 b6 7e cd 3b 36 + 92 dd bd 7f 2d 77 8b 8c 98 03 ae e3 28 09 1b 58 + fa b3 24 e4 fa d6 75 94 55 85 80 8b 48 31 d7 bc + 3f f4 de f0 8e 4b 7a 9d e5 76 d2 65 86 ce c6 4b + 61 16" + ); + const TAG: [u8; 16] = hex!("1a e1 0b 59 4f 09 e2 6a 7e 90 2e cb d0 60 06 91"); + const SYMKEY: SymKey = hex!( + "80 81 82 83 84 85 86 87 88 89 8a 8b 8c 8d 8e 8f + 90 91 92 93 94 95 96 97 98 99 9a 9b 9c 9d 9e 9f" + ); + const AAD: [u8; 12] = hex!("50 51 52 53 c0 c1 c2 c3 c4 c5 c6 c7"); + const IV: Iv = hex!("07 00 00 00 40 41 42 43 44 45 46 47"); + + /// Tests WCv2 encoding and decoding. + #[test] + fn test_decode_encoded() -> Result<()> { + let iv: &Iv = IV.as_slice().try_into()?; + let sealed = [CIPHERTEXT.as_slice(), TAG.as_slice()].concat(); + + let encoded = encode(EnvelopeType::Type0, &sealed, iv); + assert_eq!( + encoded, + "AAcAAABAQUJDREVGR9MajTRkjmDbe4avvFPvfsKkre1RKW4I/qnitac27mLWPb6kXoypZxKC+vtp2pJyixpx3gqeBgspBdaltn7NOzaS3b1/LXeLjJgDruMoCRtY+rMk5PrWdZRVhYCLSDHXvD/03vCOS3qd5XbSZYbOxkthFhrhC1lPCeJqfpAuy9BgBpE=" + ); + + let data = BASE64_STANDARD.decode(&encoded)?; + let decoded = EncodingParams::parse_decoded(&data)?; + assert_eq!(decoded.envelope_type, EnvelopeType::Type0); + assert_eq!(decoded.sealed, sealed); + assert_eq!(decoded.iv, iv); + + Ok(()) + } + + /// Tests ChaCha20-Poly1305 encryption against the RFC test vector. + /// + /// https://www.rfc-editor.org/rfc/rfc7539#section-2.8.2 + /// Please note that this test vector has an + /// "Additional Authentication Data", in practice, we will likely + /// be using this algorithm without "AAD". + #[test] + fn test_encryption() -> Result<()> { + let payload = Payload { + msg: PLAINTEXT.as_bytes(), + aad: AAD.as_slice(), + }; + let iv = IV.as_slice().try_into()?; + + let sealed = encrypt(iv, payload, &SYMKEY)?; + assert_eq!(sealed, [CIPHERTEXT.as_slice(), TAG.as_slice()].concat()); + + Ok(()) + } + + /// Tests that encrypted message can be decrypted back. + #[test] + fn test_decrypt_encrypted() -> Result<()> { + let iv = IV.as_slice().try_into()?; + + let seal_payload = Payload { + msg: PLAINTEXT.as_bytes(), + aad: AAD.as_slice(), + }; + let sealed = encrypt(iv, seal_payload, &SYMKEY)?; + + let unseal_payload = Payload { + msg: &sealed, + aad: AAD.as_slice(), + }; + let unsealed = decrypt(iv, unseal_payload, &SYMKEY)?; + + assert_eq!(PLAINTEXT.to_string(), String::from_utf8(unsealed)?); + + Ok(()) + } + + /// Tests that plain text can be WCv2 serialized and deserialized back. + #[test] + fn test_encrypt_encode_decode_decrypt() -> Result<()> { + let encoded = encrypt_and_encode(EnvelopeType::Type0, PLAINTEXT, &SYMKEY)?; + let decoded = decode_and_decrypt_type0(&encoded, &SYMKEY)?; + assert_eq!(decoded, PLAINTEXT); + + Ok(()) + } +} diff --git a/sign_api/src/crypto/session.rs b/sign_api/src/crypto/session.rs new file mode 100644 index 0000000..3c100fc --- /dev/null +++ b/sign_api/src/crypto/session.rs @@ -0,0 +1,73 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/session-proposal + +use { + anyhow::Result, + hkdf::Hkdf, + rand::{rngs::OsRng, CryptoRng, RngCore}, + sha2::{Digest, Sha256}, + std::fmt::{Debug, Formatter}, + x25519_dalek::{EphemeralSecret, PublicKey}, +}; + +#[derive(Clone)] +pub struct SessionKey { + sym_key: [u8; 32], + public_key: PublicKey, +} + +impl Debug for SessionKey { + /// Custom debug to hide the symmetrical key. + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WalletConnectUrl") + .field("sym_key", &"********") + .field("public_key", &self.public_key) + .finish() + } +} + +impl SessionKey { + /// Creates new session key from `osrng`. + /// + /// Helper for when `osrng` is good enough. + pub fn from_osrng(sender_public_key: &[u8; 32]) -> Result { + SessionKey::diffie_hellman(OsRng, sender_public_key) + } + + /// Performs Diffie-Hellman symmetric key derivation. + pub fn diffie_hellman(csprng: T, sender_public_key: &[u8; 32]) -> Result + where + T: RngCore + CryptoRng, + { + let single_use_private_key = EphemeralSecret::random_from_rng(csprng); + let public_key = PublicKey::from(&single_use_private_key); + + let ikm = single_use_private_key.diffie_hellman(&PublicKey::from(*sender_public_key)); + + let mut session_sym_key = Self { + sym_key: [0u8; 32], + public_key, + }; + let hk = Hkdf::::new(None, ikm.as_bytes()); + hk.expand(&[], &mut session_sym_key.sym_key) + .map_err(|e| anyhow::anyhow!("Failed to generate SymKey: {e}"))?; + + Ok(session_sym_key) + } + + /// Gets symmetic key reference. + pub fn symmetric_key(&self) -> &[u8; 32] { + &self.sym_key + } + + /// Gets "our" public key used in symmetric key derivation. + pub fn diffie_public_key(&self) -> &[u8; 32] { + self.public_key.as_bytes() + } + + /// Generates new session topic. + pub fn generate_topic(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(self.sym_key); + hex::encode(hasher.finalize()) + } +} diff --git a/sign_api/src/lib.rs b/sign_api/src/lib.rs new file mode 100644 index 0000000..df91bc0 --- /dev/null +++ b/sign_api/src/lib.rs @@ -0,0 +1,3 @@ +pub mod crypto; +pub mod pairing_uri; +pub mod rpc; diff --git a/sign_api/src/pairing_uri.rs b/sign_api/src/pairing_uri.rs new file mode 100644 index 0000000..89b9260 --- /dev/null +++ b/sign_api/src/pairing_uri.rs @@ -0,0 +1,164 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/core/pairing/pairing-uri + +use { + anyhow::Result, + lazy_static::lazy_static, + regex::Regex, + std::{ + fmt::{Debug, Formatter}, + str::FromStr, + }, + url::Url, +}; + +lazy_static! { + static ref TOPIC_AND_VERSION_RE: Regex = + Regex::new(r"^(?P[[[:word:]]-]+)@(?P\d+)$") + .expect("invalid TOPIC_AND_VERSION_RE in wallet_connect"); +} + +#[derive(Debug, Clone, thiserror::Error, PartialEq)] +pub enum ParseError { + #[error("Expecting protocol \"wc\" but \"{protocol}\" is found.")] + UnexpectedProtocol { protocol: String }, + #[error(transparent)] + Url(#[from] url::ParseError), + #[error("Failed to parse topic and version")] + InvalidTopicAndVersion, + #[error("Topic not found")] + TopicNotFound, + #[error("Version not found")] + VersionNotFound, + #[error("Relay protocol not found")] + RelayProtocolNotFound, + #[error("Key not found")] + KeyNotFound, + #[error("Failed to parse key: {0:?}")] + InvalidKey(#[from] hex::FromHexError), + #[error("Unexpected parameter, key: {0:?}, value: {1:?}")] + UnexpectedParameter(String, String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Params { + pub relay_protocol: String, + pub sym_key: Vec, + pub relay_data: Option, +} + +/// https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1328.md +#[derive(Clone, Eq, PartialEq)] +pub struct Pairing { + pub topic: String, + pub version: String, + pub params: Params, +} + +impl Pairing { + fn parse_topic_and_version(path: &str) -> Result<(String, String), ParseError> { + let caps = TOPIC_AND_VERSION_RE + .captures(path) + .ok_or(ParseError::InvalidTopicAndVersion)?; + let topic = caps + .name("topic") + .ok_or(ParseError::TopicNotFound)? + .as_str() + .to_owned(); + let version = caps + .name("version") + .ok_or(ParseError::VersionNotFound)? + .as_str() + .to_owned(); + Ok((topic, version)) + } + + fn parse_params(url: &Url) -> Result { + let queries = url.query_pairs(); + + let mut relay_protocol: Option = None; + let mut sym_key: Option = None; + let mut relay_data: Option = None; + for (k, v) in queries { + match k.as_ref() { + "relay-protocol" => relay_protocol = Some((*v).to_owned()), + "symKey" => sym_key = Some((*v).to_owned()), + "relay-data" => relay_data = Some((*v).to_owned()), + _ => { + return Result::Err(ParseError::UnexpectedParameter( + (*k).to_owned(), + (*v).to_owned(), + )) + } + } + } + + Ok(Params { + relay_protocol: relay_protocol.ok_or(ParseError::RelayProtocolNotFound)?, + sym_key: hex::decode(sym_key.ok_or(ParseError::KeyNotFound)?)?, + relay_data, + }) + } +} + +impl Debug for Pairing { + /// Debug with key masked. + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WalletConnectUrl") + .field("topic", &self.topic) + .field("version", &self.version) + .field("relay-protocol", &self.params.relay_protocol) + .field("key", &"***") + .field( + "relay-data", + &self.params.relay_data.as_deref().unwrap_or(""), + ) + .finish() + } +} + +impl FromStr for Pairing { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + let url = Url::from_str(s)?; + + if url.scheme() != "wc" { + return Result::Err(ParseError::UnexpectedProtocol { + protocol: url.scheme().to_owned(), + }); + } + + let (topic, version) = Self::parse_topic_and_version(url.path())?; + Ok(Self { + topic, + version, + params: Self::parse_params(&url)?, + }) + } +} + +#[cfg(test)] +mod tests { + use hex_literal::hex; + + use super::*; + + #[test] + fn parse_uri() { + let uri = "wc:c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168@2?relay-protocol=waku&symKey=7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b"; + + let actual = Pairing { + topic: "c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168".to_owned(), + version: "2".to_owned(), + params: Params { + relay_protocol: "waku".to_owned(), + sym_key: hex!("7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b") + .into(), + relay_data: None, + }, + }; + let expected = Pairing::from_str(uri).unwrap(); + + assert_eq!(actual, expected); + } +} diff --git a/sign_api/src/rpc.rs b/sign_api/src/rpc.rs new file mode 100644 index 0000000..7269942 --- /dev/null +++ b/sign_api/src/rpc.rs @@ -0,0 +1,138 @@ +//! The crate exports common types used when interacting with messages between +//! clients. This also includes communication over HTTP between relays. + +mod params; + +use { + anyhow::Result, + chrono::Utc, + serde::{Deserialize, Serialize}, + std::{fmt::Debug, sync::Arc}, +}; + +pub use params::*; + +/// Version of the WalletConnect protocol that we're implementing. +pub const JSON_RPC_VERSION_STR: &str = "2.0"; + +pub static JSON_RPC_VERSION: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| Arc::from(JSON_RPC_VERSION_STR)); + +/// Errors covering payload validation problems. +#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] +pub enum ValidationError { + #[error("Invalid request ID")] + RequestId, + + #[error("Invalid JSON RPC version")] + JsonRpcVersion, +} + +/// Enum representing a JSON RPC payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Payload { + Request(Request), + Response(Response), +} + +impl From for Payload { + fn from(value: Request) -> Self { + Payload::Request(value) + } +} + +impl From for Payload { + fn from(value: Response) -> Self { + Payload::Response(value) + } +} + +impl Payload { + /// Returns the message ID contained within the payload. + pub fn id(&self) -> u64 { + match self { + Self::Request(req) => req.id, + Self::Response(res) => res.id, + } + } + + pub fn validate(&self) -> Result<(), ValidationError> { + match self { + Self::Request(request) => request.validate(), + Self::Response(response) => response.validate(), + } + } + + pub fn irn_tag_in_range(tag: u32) -> bool { + (1100..=1115).contains(&tag) + } +} + +/// Data structure representing a JSON RPC request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Request { + /// ID this message corresponds to. + pub id: u64, + + /// The JSON RPC version. + pub jsonrpc: Arc, + + /// The parameters required to fulfill this request. + #[serde(flatten)] + pub params: RequestParams, +} + +impl Request { + /// Create a new instance. + pub fn new(params: RequestParams) -> Self { + Self { + id: Utc::now().timestamp_micros() as u64, + jsonrpc: JSON_RPC_VERSION_STR.into(), + params, + } + } + + /// Validates the request payload. + pub fn validate(&self) -> Result<(), ValidationError> { + if self.jsonrpc.as_ref() != JSON_RPC_VERSION_STR { + return Err(ValidationError::JsonRpcVersion); + } + + Ok(()) + } +} + +/// Data structure representing JSON RPC response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Response { + /// ID this message corresponds to. + pub id: u64, + + /// RPC version. + pub jsonrpc: Arc, + + /// The parameters required to fulfill this response. + #[serde(flatten)] + pub params: ResponseParams, +} + +impl Response { + /// Create a new instance. + pub fn new(id: u64, params: ResponseParams) -> Self { + Self { + id, + jsonrpc: JSON_RPC_VERSION.clone(), + params, + } + } + + /// Validates the parameters. + pub fn validate(&self) -> Result<(), ValidationError> { + if self.jsonrpc.as_ref() != JSON_RPC_VERSION_STR { + return Err(ValidationError::JsonRpcVersion); + } + + Ok(()) + } +} diff --git a/sign_api/src/rpc/params.rs b/sign_api/src/rpc/params.rs new file mode 100644 index 0000000..79208d2 --- /dev/null +++ b/sign_api/src/rpc/params.rs @@ -0,0 +1,219 @@ +pub(super) mod session_delete; +pub(super) mod session_event; +pub(super) mod session_extend; +pub(super) mod session_ping; +pub(super) mod session_propose; +pub(super) mod session_request; +pub(super) mod session_settle; +pub(super) mod session_update; +pub(super) mod shared_types; + +pub use { + session_delete::*, session_event::*, session_extend::*, session_ping::*, session_propose::*, + session_request::*, session_settle::*, session_update::*, shared_types::*, +}; + +use { + anyhow::Result, + paste::paste, + serde::{Deserialize, Serialize}, + serde_json::Value, +}; + +/// Relay protocol metadata. +/// +/// https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +pub trait RelayProtocolMetadata { + /// Retrieves IRN relay protocol metadata. + /// + /// Every method must return corresponding IRN metadata. + fn irn_metadata(&self) -> IrnMetadata; +} + +pub trait RelayProtocolHelpers { + type Params; + + /// Converts "unnamed" payload parameters into typed. + /// + /// Example: success and error response payload does not specify the + /// method. Thus the only way to deserialize the data into typed + /// parameters, is to use the tag to determine the response method. + /// + /// This is a convenience method, so that users don't have to deal + /// with the tags directly. + fn irn_try_from_tag(value: Value, tag: u32) -> Result; +} + +/// Relay IRN protocol metadata. +/// +/// https://specs.walletconnect.com/2.0/specs/servers/relay/relay-server-rpc +/// #definitions +#[derive(Debug, Clone, Copy)] +pub struct IrnMetadata { + pub tag: u32, + pub ttl: u64, + pub prompt: bool, +} + +// Convenience macro to de-duplicate implementation for different parameter sets. +macro_rules! impl_relay_protocol_metadata { + ($param_type:ty,$meta:ident) => { + paste! { + impl RelayProtocolMetadata for $param_type { + fn irn_metadata(&self) -> IrnMetadata { + match self { + [<$param_type>]::SessionPropose(_) => session_propose::[], + [<$param_type>]::SessionSettle(_) => session_settle::[], + [<$param_type>]::SessionUpdate(_) => session_update::[], + [<$param_type>]::SessionExtend(_) => session_extend::[], + [<$param_type>]::SessionRequest(_) => session_request::[], + [<$param_type>]::SessionEvent(_) => session_event::[], + [<$param_type>]::SessionDelete(_) => session_delete::[], + [<$param_type>]::SessionPing(_) => session_ping::[], + } + } + } + } + } +} + +// Convenience macro to de-duplicate implementation for different parameter sets. +macro_rules! impl_relay_protocol_helpers { + ($param_type:ty) => { + paste! { + impl RelayProtocolHelpers for $param_type { + type Params = Self; + + fn irn_try_from_tag(value: Value, tag: u32) -> Result { + if tag == session_propose::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionPropose(serde_json::from_value(value)?)) + } else if tag == session_settle::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionSettle(serde_json::from_value(value)?)) + } else if tag == session_update::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionUpdate(serde_json::from_value(value)?)) + } else if tag == session_extend::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionExtend(serde_json::from_value(value)?)) + } else if tag == session_request::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionRequest(serde_json::from_value(value)?)) + } else if tag == session_event::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionEvent(serde_json::from_value(value)?)) + } else if tag == session_delete::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionDelete(serde_json::from_value(value)?)) + } else if tag == session_ping::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionPing(serde_json::from_value(value)?)) + } else { + anyhow::bail!("tag={tag}, does not match Sign API methods") + } + } + } + } + }; +} + +/// Sign API request parameters. +/// +/// https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +/// https://specs.walletconnect.com/2.0/specs/clients/sign/data-structures +#[derive(Debug, Serialize, Eq, Deserialize, Clone, PartialEq)] +#[serde(tag = "method", content = "params")] +pub enum RequestParams { + #[serde(rename = "wc_sessionPropose")] + SessionPropose(SessionProposeRequest), + #[serde(rename = "wc_sessionSettle")] + SessionSettle(SessionSettleRequest), + #[serde(rename = "wc_sessionUpdate")] + SessionUpdate(SessionUpdateRequest), + #[serde(rename = "wc_sessionExtend")] + SessionExtend(SessionExtendRequest), + #[serde(rename = "wc_sessionRequest")] + SessionRequest(SessionRequestRequest), + #[serde(rename = "wc_sessionEvent")] + SessionEvent(SessionEventRequest), + #[serde(rename = "wc_sessionDelete")] + SessionDelete(SessionDeleteRequest), + #[serde(rename = "wc_sessionPing")] + SessionPing(()), +} +impl_relay_protocol_metadata!(RequestParams, request); + +/// https://www.jsonrpc.org/specification#response_object +/// +/// JSON RPC 2.0 response object can either carry success or error data. +/// Please note, that relay protocol metadata is used to disambiguate the +/// response data. +/// +/// For example: +/// `RelayProtocolHelpers::irn_try_from_tag` is used to deserialize an opaque +/// response data into the typed parameters. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResponseParams { + /// A response with a result. + #[serde(rename = "result")] + Success(Value), + + /// A response for a failed request. + #[serde(rename = "error")] + Err(Value), +} + +/// Typed success response parameters. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponseParamsSuccess { + SessionPropose(SessionProposeResponse), + SessionSettle(bool), + SessionUpdate(bool), + SessionExtend(bool), + SessionRequest(bool), + SessionEvent(bool), + SessionDelete(bool), + SessionPing(bool), +} +impl_relay_protocol_metadata!(ResponseParamsSuccess, response); +impl_relay_protocol_helpers!(ResponseParamsSuccess); + +impl TryFrom for ResponseParams { + type Error = anyhow::Error; + + fn try_from(value: ResponseParamsSuccess) -> Result { + Ok(Self::Success(serde_json::to_value(value)?)) + } +} + +/// Response error data. +/// +/// The documentation states that both fields are required. +/// However, on session expiry error, "empty" error is received. +#[derive(Debug, Clone, Eq, Serialize, Deserialize, PartialEq)] +pub struct ErrorParams { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub message: Option, +} + +/// Typed error response parameters. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponseParamsError { + SessionPropose(ErrorParams), + SessionSettle(ErrorParams), + SessionUpdate(ErrorParams), + SessionExtend(ErrorParams), + SessionRequest(ErrorParams), + SessionEvent(ErrorParams), + SessionDelete(ErrorParams), + SessionPing(ErrorParams), +} +impl_relay_protocol_metadata!(ResponseParamsError, response); +impl_relay_protocol_helpers!(ResponseParamsError); + +impl TryFrom for ResponseParams { + type Error = anyhow::Error; + + fn try_from(value: ResponseParamsError) -> Result { + Ok(Self::Err(serde_json::to_value(value)?)) + } +} diff --git a/sign_api/src/rpc/params/session_delete.rs b/sign_api/src/rpc/params/session_delete.rs new file mode 100644 index 0000000..71d1c84 --- /dev/null +++ b/sign_api/src/rpc/params/session_delete.rs @@ -0,0 +1,25 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessiondelete + +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1112, + ttl: 86400, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1113, + ttl: 86400, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionDeleteRequest { + pub code: i64, + pub message: String, +} diff --git a/sign_api/src/rpc/params/session_event.rs b/sign_api/src/rpc/params/session_event.rs new file mode 100644 index 0000000..b950e9e --- /dev/null +++ b/sign_api/src/rpc/params/session_event.rs @@ -0,0 +1,32 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionevent + +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1110, + ttl: 300, + prompt: true, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1111, + ttl: 300, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Event { + name: String, + data: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionEventRequest { + event: Event, + chain_id: String, +} diff --git a/sign_api/src/rpc/params/session_extend.rs b/sign_api/src/rpc/params/session_extend.rs new file mode 100644 index 0000000..5d8f918 --- /dev/null +++ b/sign_api/src/rpc/params/session_extend.rs @@ -0,0 +1,24 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionextend + +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1106, + ttl: 86400, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1107, + ttl: 86400, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionExtendRequest { + pub expiry: u64, +} diff --git a/sign_api/src/rpc/params/session_ping.rs b/sign_api/src/rpc/params/session_ping.rs new file mode 100644 index 0000000..385e015 --- /dev/null +++ b/sign_api/src/rpc/params/session_ping.rs @@ -0,0 +1,22 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionping + +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1114, + ttl: 30, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1115, + ttl: 30, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionPingRequest {} diff --git a/sign_api/src/rpc/params/session_propose.rs b/sign_api/src/rpc/params/session_propose.rs new file mode 100644 index 0000000..f70c876 --- /dev/null +++ b/sign_api/src/rpc/params/session_propose.rs @@ -0,0 +1,40 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionpropose + +use serde::{Deserialize, Serialize}; + +use super::{IrnMetadata, Metadata, Namespaces, Relay}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1100, + ttl: 300, + prompt: true, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1101, + ttl: 300, + prompt: false, +}; + +#[derive(Debug, Serialize, Eq, PartialEq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Proposer { + pub public_key: String, + pub metadata: Metadata, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionProposeRequest { + pub relays: Vec, + pub proposer: Proposer, + pub required_namespaces: Namespaces, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionProposeResponse { + pub relay: Relay, + pub responder_public_key: String, +} diff --git a/sign_api/src/rpc/params/session_request.rs b/sign_api/src/rpc/params/session_request.rs new file mode 100644 index 0000000..d4f81e3 --- /dev/null +++ b/sign_api/src/rpc/params/session_request.rs @@ -0,0 +1,32 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionrequest + +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1108, + ttl: 300, + prompt: true, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1109, + ttl: 300, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Request { + method: String, + params: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionRequestRequest { + pub request: Request, + pub chain_id: String, +} diff --git a/sign_api/src/rpc/params/session_settle.rs b/sign_api/src/rpc/params/session_settle.rs new file mode 100644 index 0000000..3b67ce6 --- /dev/null +++ b/sign_api/src/rpc/params/session_settle.rs @@ -0,0 +1,57 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionsettle + +use serde::{Deserialize, Serialize}; + +use super::{IrnMetadata, Metadata, Relay}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1102, + ttl: 300, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1103, + ttl: 300, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Controller { + pub public_key: String, + pub metadata: Metadata, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettleNamespaces { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub eip155: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub cosmos: Option, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettleNamespace { + pub accounts: Vec, + pub methods: Vec, + pub events: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub extensions: Option>, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SessionSettleRequest { + pub relay: Relay, + pub controller: Controller, + pub namespaces: SettleNamespaces, + /// uSecs contrary to what documentation says (secs). + pub expiry: u64, +} diff --git a/sign_api/src/rpc/params/session_update.rs b/sign_api/src/rpc/params/session_update.rs new file mode 100644 index 0000000..d8f2546 --- /dev/null +++ b/sign_api/src/rpc/params/session_update.rs @@ -0,0 +1,24 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionupdate + +use serde::{Deserialize, Serialize}; + +use super::{IrnMetadata, Namespaces}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1104, + ttl: 86400, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1105, + ttl: 86400, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionUpdateRequest { + pub namespaces: Namespaces, +} diff --git a/sign_api/src/rpc/params/shared_types.rs b/sign_api/src/rpc/params/shared_types.rs new file mode 100644 index 0000000..bd5c61e --- /dev/null +++ b/sign_api/src/rpc/params/shared_types.rs @@ -0,0 +1,141 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/data-structures + +use anyhow::{Ok, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Metadata { + pub description: String, + pub url: String, + pub icons: Vec, + pub name: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +pub struct Relay { + pub protocol: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub data: Option, +} + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces +#[derive(Debug, Serialize, Eq, PartialEq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Namespaces { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub eip155: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub cosmos: Option, +} + +impl Namespaces { + /// Ensures that application is compatible with the requester requirements. + /// + /// Implementation must support at least all the elements in `required`. + pub fn supported(&self, required: &Namespaces) -> Result<()> { + if self.eip155.is_none() && self.cosmos.is_none() { + return Err(anyhow::anyhow!("No namespaces found")); + } + + match (&required.eip155, &self.eip155) { + (Some(other), Some(this)) => { + return this.supported(other); + } + (Some(_), None) => { + return Err(anyhow::anyhow!("eip155 namespace is required but missing")); + } + (None, Some(_)) | (None, None) => {} + } + + match (&required.cosmos, &self.cosmos) { + (Some(other), Some(this)) => { + return this.supported(other); + } + (Some(_), None) => { + return Err(anyhow::anyhow!("Cosmos namespace is required but missing")); + } + (None, Some(_)) | (None, None) => {} + } + + Ok(()) + } +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Namespace { + pub chains: Vec, + pub methods: Vec, + pub events: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub extensions: Option>, +} + +impl Namespace { + /// Ensures that application is compatible with the requester requirements. + /// + /// Implementation must support at least all the elements in `required`. + pub fn supported(&self, required: &Namespace) -> Result<()> { + if !required + .chains + .iter() + .all(|item| self.chains.contains(item)) + { + return Err(anyhow::anyhow!( + "Chain/chains not supported, actual: {:?}, expected: {:?}", + self.chains, + required.chains, + )); + } + + if !required + .methods + .iter() + .all(|item| self.methods.contains(item)) + { + return Err(anyhow::anyhow!( + "Method/methods not supported, actual: {:?}, expected: {:?}", + self.methods, + required.methods, + )); + } + + if !required + .events + .iter() + .all(|item| self.events.contains(item)) + { + return Err(anyhow::anyhow!( + "Event/events not supported, actual: {:?}, expected: {:?}", + self.events, + required.events, + )); + } + + match (&self.extensions, &required.extensions) { + (Some(this), Some(other)) => { + if !other.iter().all(|item| this.contains(item)) { + return Err(anyhow::anyhow!( + "Extension/extensions not supported, actual: {:?}, expected: {:?}", + this, + other, + )); + } + } + (Some(other), None) => { + return Err(anyhow::anyhow!( + "Extension/extensions not supported, actual: , expected: {:?}", + other, + )); + } + (None, Some(_)) | (None, None) => {} + } + + Ok(()) + } +}