From 4c80ae04faee0fed5b0b044cb8707929cb1ff1f9 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 27 Aug 2024 11:17:19 +0100 Subject: [PATCH 01/18] save dev state --- Cargo.toml | 4 +++- relay_client/src/websocket/connection.rs | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4fbf75d..6a4fcce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ authors = ["WalletConnect Team"] license = "Apache-2.0" [workspace] -members = ["blockchain_api", "relay_client", "relay_rpc"] +members = ["blockchain_api", "pairing_api", "relay_client", "relay_rpc"] [features] default = ["full"] @@ -15,10 +15,12 @@ full = ["client", "rpc", "http"] client = ["dep:relay_client"] http = ["relay_client/http"] rpc = ["dep:relay_rpc"] +pairing = ["dep:pairing_api"] [dependencies] relay_client = { path = "./relay_client", optional = true } relay_rpc = { path = "./relay_rpc", optional = true } +pairing_api = { path = "./pairing_api", optional = true } [dev-dependencies] anyhow = "1" diff --git a/relay_client/src/websocket/connection.rs b/relay_client/src/websocket/connection.rs index 20a4d82..370b132 100644 --- a/relay_client/src/websocket/connection.rs +++ b/relay_client/src/websocket/connection.rs @@ -123,7 +123,6 @@ impl Connection { match stream { Some(mut stream) => stream.close(None).await, - None => Err(WebsocketClientError::ClosingFailed(TransportError::AlreadyClosed).into()), } } From 7f6470b9b38aaaa724dc315a78f0993d1fdc80b0 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 27 Aug 2024 11:17:25 +0100 Subject: [PATCH 02/18] save dev state --- pairing_api/Cargo.toml | 15 +++ pairing_api/src/crypto.rs | 205 +++++++++++++++++++++++++++++ pairing_api/src/lib.rs | 3 + pairing_api/src/pairing_url.rs | 190 ++++++++++++++++++++++++++ pairing_api/src/session.rs | 77 +++++++++++ pairing_api/src/session/propose.rs | 1 + 6 files changed, 491 insertions(+) create mode 100644 pairing_api/Cargo.toml create mode 100644 pairing_api/src/crypto.rs create mode 100644 pairing_api/src/lib.rs create mode 100644 pairing_api/src/pairing_url.rs create mode 100644 pairing_api/src/session.rs create mode 100644 pairing_api/src/session/propose.rs diff --git a/pairing_api/Cargo.toml b/pairing_api/Cargo.toml new file mode 100644 index 0000000..5f00b60 --- /dev/null +++ b/pairing_api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pairing_api" +version = "0.1.0" +edition = "2021" + +[dependencies] +base64 = "0.21.2" +chacha20poly1305 = "0.9.1" +hex = "0.4.2" +hkdf = "0.12.4" +lazy_static = "1.4" +regex = "1.10.6" +thiserror = "1.0" +url = "2.3" +x25519-dalek = { version = "2.0", features = ["static_secrets"] } diff --git a/pairing_api/src/crypto.rs b/pairing_api/src/crypto.rs new file mode 100644 index 0000000..0999c6f --- /dev/null +++ b/pairing_api/src/crypto.rs @@ -0,0 +1,205 @@ +use { + base64::{prelude::BASE64_STANDARD, DecodeError, Engine}, + chacha20poly1305::{ + aead::{Aead, KeyInit, OsRng, Payload}, + AeadCore, + ChaCha20Poly1305, + Nonce, + }, + std::string::FromUtf8Error, +}; + +// 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 INIT_VEC_LEN: usize = 12; +const PUB_KEY_LENGTH: usize = 32; +const SYM_KEY_LENGTH: usize = 32; + +pub type InitVec = [u8; INIT_VEC_LEN]; +pub type SymKey = [u8; SYM_KEY_LENGTH]; +pub type PubKey = [u8; PUB_KEY_LENGTH]; + +/// Payload encoding, decoding, encryption and decryption errors. +#[derive(Debug, thiserror::Error)] +pub enum PayloadError { + #[error("Payload is not base64 encoded")] + Base64Decode(#[from] DecodeError), + #[error("Payload decryption failure: {0}")] + Decryption(String), + #[error("Payload encryption failure: {0}")] + Encryption(String), + #[error("Invalid Initialization Vector length={0}")] + InitVecLen(usize), + #[error("Invalid symmetrical key length={0}")] + SymKeyLen(usize), + #[error("Payload does not fit initialization vector (index: {0}..{1})")] + ParseInitVecLen(usize, usize), + #[error("Payload does not fit sender public key (index: {0}..{1})")] + ParseSenderPublicKeyLen(usize, usize), + #[error("Payload is not a valid JSON encoding")] + PayloadJson(#[from] FromUtf8Error), + #[error("Unsupported envelope type={0}")] + UnsupportedEnvelopeType(u8), + #[error("Unexpected envelope type={0}, expected={1}")] + UnexpectedEnvelopeType(u8, u8), +} + +#[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. + init_vec: &'a InitVec, + 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 init_vec_start_index: usize = TYPE_INDEX + TYPE_LENGTH; + let init_vec_end_index: usize = init_vec_start_index + INIT_VEC_LEN; + let sealed_start_index: usize = init_vec_end_index; + Ok(EncodingParams { + init_vec: data[init_vec_start_index..init_vec_end_index] + .try_into() + .map_err(|_| { + PayloadError::ParseInitVecLen(init_vec_start_index, init_vec_end_index) + })?, + sealed: &data[sealed_start_index..], + envelope_type: EnvelopeType::Type0, + }) + } + TYPE_1 => { + let key_start_index: usize = TYPE_INDEX + TYPE_LENGTH; + let key_end_index: usize = key_start_index + PUB_KEY_LENGTH; + let init_vec_start_index: usize = key_end_index; + let init_vec_end_index: usize = init_vec_start_index + INIT_VEC_LEN; + let sealed_start_index: usize = init_vec_end_index; + let init_vec = data[init_vec_start_index..init_vec_end_index] + .try_into() + .map_err(|_| { + PayloadError::ParseInitVecLen(init_vec_start_index, init_vec_end_index) + })?; + let envelope_type = EnvelopeType::Type1 { + sender_public_key: &data[sealed_start_index..key_end_index].try_into().map_err( + |_| { + PayloadError::ParseSenderPublicKeyLen( + init_vec_start_index, + init_vec_end_index, + ) + }, + ), + }; + + Ok(EncodingParams { + envelope_type, + init_vec, + sealed: &data[sealed_start_index..], + }) + } + _ => Err(PayloadError::UnsupportedEnvelopeType(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() + .map_err(|_| PayloadError::InitVecLen(nonce.len()))?, + )) +} + +/// 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 { + return Err(PayloadError::UnexpectedEnvelopeType(TYPE_1, TYPE_0)); + } + + let payload = Payload { + msg: decoded.sealed, + aad: &[], + }; + let decrypted = decrypt( + decoded + .init_vec + .try_into() + .map_err(|_| PayloadError::InitVecLen(decoded.init_vec.len()))?, + payload, + key, + )?; + + Ok(String::from_utf8(decrypted)?) +} + +fn encrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result, PayloadError> { + let cipher = ChaCha20Poly1305::new( + key.try_into() + .map_err(|_| PayloadError::SymKeyLen(key.len()))?, + ); + let sealed = cipher + .encrypt(nonce, payload) + .map_err(|e| PayloadError::Encryption(e.to_string()))?; + + Ok(sealed) +} + +fn encode(envelope_type: EnvelopeType, sealed: &[u8], init_vec: &InitVec) -> String { + match envelope_type { + EnvelopeType::Type0 => { + BASE64_STANDARD.encode([&[TYPE_0], init_vec.as_slice(), sealed].concat()) + } + EnvelopeType::Type1 { sender_public_key } => BASE64_STANDARD + .encode([&[TYPE_1], sender_public_key.as_slice(), init_vec, sealed].concat()), + } +} + +fn decrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result, PayloadError> { + let cipher = ChaCha20Poly1305::new( + key.try_into() + .map_err(|_| PayloadError::SymKeyLen(key.len()))?, + ); + let unsealed = cipher + .decrypt(nonce, payload) + .map_err(|e| PayloadError::Decryption(e.to_string()))?; + + Ok(unsealed) +} diff --git a/pairing_api/src/lib.rs b/pairing_api/src/lib.rs new file mode 100644 index 0000000..8f251fb --- /dev/null +++ b/pairing_api/src/lib.rs @@ -0,0 +1,3 @@ +mod crypto; +mod pairing_url; +mod session; diff --git a/pairing_api/src/pairing_url.rs b/pairing_api/src/pairing_url.rs new file mode 100644 index 0000000..2c10e09 --- /dev/null +++ b/pairing_api/src/pairing_url.rs @@ -0,0 +1,190 @@ +// topic = "7f6e504bfad60b485450578e05678ed3e8e8c4751d3c6160be17160d63ec90f9" +// version = 2 +// symKey = "587d5484ce2a2a6ee3ba1962fdd7e8588e06200c46823bd18fbd67def96ad303" +// methods = [wc_sessionPropose],[wc_authRequest,wc_authBatchRequest] +// relay = { protocol: "irn", data: "" } +// Required + +// symKey (STRING) = symmetric key used for pairing encryption +// methods (STRING) = comma separated array of inner arrays of methods. Inner +// arrays are grouped by ProtocolType relay-protocol (STRING) = protocol name +// used for relay Optional + +// relay-data (STRING) = hex data payload used for relay +// expiryTimestamp (UINT) = unixr timestamp in seconds - after the timestamp the +// pairing is considered expired, should be generated 5 minutes in the future + +use { + lazy_static::lazy_static, + regex::Regex, + std::{collections::HashMap, str::FromStr}, + thiserror::Error, + url::Url, +}; + +lazy_static! { + static ref TOPIC_VERSION_REGEX: Regex = + Regex::new(r"^(?P[[:word:]-]+)@(?P\d+)$").expect("Failed to compile regex"); +} + +#[derive(PartialEq, Eq)] +pub struct PairingParams { + pub sym_key: Vec, + pub relay_protocol: String, + pub relay_data: Option, + pub expiry_timestamp: Option, +} + +#[derive(PartialEq, Eq)] +pub struct Pairing { + pub topic: String, + pub version: String, + pub params: PairingParams, +} + +impl std::fmt::Debug for Pairing { + /// Debug with key masked. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WCPairingUrl") + .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).map_err(|err| ParseError::InvalidData(err.to_string()))?; + if url.scheme() != "wc" { + return Result::Err(ParseError::UnexpectedProtocol(url.scheme().to_owned())); + } + + let (topic, version) = Self::try_topic_and_version_from_path(url.path())?; + let params = Self::try_params_from_url(&url)?; + + Ok(Self { + topic, + version, + params, + }) + } +} + +impl Pairing { + fn try_topic_and_version_from_path(path: &str) -> Result<(String, String), ParseError> { + let caps = TOPIC_VERSION_REGEX + .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)) + } + + /// Try to parse WalletConnect pairing url + fn try_params_from_url(url: &Url) -> Result { + let mut params = HashMap::new(); + let queries = url.query_pairs(); + + for (key, value) in queries { + if let Some(existing) = params.insert(key.to_string(), value.to_string()) { + return Err(ParseError::UnexpectedParameter(key.into_owned(), existing)); + } + } + + let relay_protocol = params + .remove("relay-protocol") + .ok_or(ParseError::RelayProtocolNotFound)?; + + let sym_key = params + .remove("symKey") + .ok_or(ParseError::KeyNotFound) + .and_then(|key| hex::decode(key).map_err(ParseError::InvalidSymKey))?; + + let relay_data = params.remove("relay-data"); + let expiry_timestamp = params + .remove("expiryTimestamp") + .and_then(|t| t.parse::().ok()); + + if !params.is_empty() { + let (key, value) = params.iter().next().unwrap(); + return Err(ParseError::UnexpectedParameter(key.clone(), value.clone())); + } + + Ok(PairingParams { + relay_protocol, + sym_key, + relay_data, + expiry_timestamp, + }) + } +} + +#[derive(Error, Debug)] +pub enum ParseError { + #[error("Invalid topic and version format")] + InvalidTopicAndVersion, + #[error("Topic not found")] + TopicNotFound, + #[error("Version not found")] + VersionNotFound, + #[error("Relay protocol not found")] + RelayProtocolNotFound, + #[error("Symmetric key not found")] + KeyNotFound, + #[error("Invalid symmetric key: {0}")] + InvalidSymKey(#[from] hex::FromHexError), + #[error("Invalid data: {0}")] + InvalidData(String), + #[error("Unexpected parameter: {0} = {1}")] + UnexpectedParameter(String, String), + #[error("Unexpected protocol: {0}")] + UnexpectedProtocol(String), +} + +mod tests { + use super::*; + + #[test] + fn parse_uri() { + let uri = "wc:c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168@2?\ + relay-protocol=irn&\ + symKey=7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b"; + + let actual = Pairing { + topic: "c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168".to_owned(), + version: "2".to_owned(), + params: PairingParams { + relay_protocol: "irn".to_owned(), + sym_key: hex::decode( + "7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b", + ) + .unwrap() + .into(), + relay_data: None, + expiry_timestamp: None, + }, + }; + let expected = Pairing::from_str(uri).unwrap(); + + assert_eq!(actual, expected); + } +} diff --git a/pairing_api/src/session.rs b/pairing_api/src/session.rs new file mode 100644 index 0000000..d4c3fac --- /dev/null +++ b/pairing_api/src/session.rs @@ -0,0 +1,77 @@ +use { + std::fmt::Debug, + x25519_dalek::{EphemeralSecret, PublicKey}, +}; + +mod propose; + +enum WcSessionKind { + SessionProposal, + SessionRequest, + SessionUpdate, + SessionDelete, + SessionEvent, + SessionPing, + SessiongExpire, + SessionExtend, + ProposalExpire, +} + +struct SessionKey { + sym_key: [u8; 32], + public_key: PublicKey, +} + +impl std::fmt::Debug for SessionKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionKey") + .field("sym_key", &"*******") + .field("public_key", &self.public_key) + .finish() + } +} + +impl SessionKey { + /// Creates new session key from `osrng`. + 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| SessionError::SymKeyGeneration(e.to_string()))?; + + 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/pairing_api/src/session/propose.rs b/pairing_api/src/session/propose.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/pairing_api/src/session/propose.rs @@ -0,0 +1 @@ + From 80ef97891cf8976d38ad55d71237d37799f668a5 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 27 Aug 2024 15:04:46 +0100 Subject: [PATCH 03/18] save dev state --- Cargo.toml | 6 +- pairing_api/Cargo.toml | 15 --- pairing_api/src/crypto.rs | 205 ----------------------------- pairing_api/src/lib.rs | 3 - pairing_api/src/pairing_url.rs | 190 -------------------------- pairing_api/src/session.rs | 77 ----------- pairing_api/src/session/propose.rs | 1 - relay_rpc/Cargo.toml | 4 +- 8 files changed, 5 insertions(+), 496 deletions(-) delete mode 100644 pairing_api/Cargo.toml delete mode 100644 pairing_api/src/crypto.rs delete mode 100644 pairing_api/src/lib.rs delete mode 100644 pairing_api/src/pairing_url.rs delete mode 100644 pairing_api/src/session.rs delete mode 100644 pairing_api/src/session/propose.rs diff --git a/Cargo.toml b/Cargo.toml index 6a4fcce..7dbefd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ authors = ["WalletConnect Team"] license = "Apache-2.0" [workspace] -members = ["blockchain_api", "pairing_api", "relay_client", "relay_rpc"] +members = ["blockchain_api", "sign_api", "relay_client", "relay_rpc"] [features] default = ["full"] @@ -15,12 +15,12 @@ full = ["client", "rpc", "http"] client = ["dep:relay_client"] http = ["relay_client/http"] rpc = ["dep:relay_rpc"] -pairing = ["dep:pairing_api"] +sign_api = ["dep:sign_api"] [dependencies] relay_client = { path = "./relay_client", optional = true } relay_rpc = { path = "./relay_rpc", optional = true } -pairing_api = { path = "./pairing_api", optional = true } +sign_api = { path = "./sign_api", optional = true } [dev-dependencies] anyhow = "1" diff --git a/pairing_api/Cargo.toml b/pairing_api/Cargo.toml deleted file mode 100644 index 5f00b60..0000000 --- a/pairing_api/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "pairing_api" -version = "0.1.0" -edition = "2021" - -[dependencies] -base64 = "0.21.2" -chacha20poly1305 = "0.9.1" -hex = "0.4.2" -hkdf = "0.12.4" -lazy_static = "1.4" -regex = "1.10.6" -thiserror = "1.0" -url = "2.3" -x25519-dalek = { version = "2.0", features = ["static_secrets"] } diff --git a/pairing_api/src/crypto.rs b/pairing_api/src/crypto.rs deleted file mode 100644 index 0999c6f..0000000 --- a/pairing_api/src/crypto.rs +++ /dev/null @@ -1,205 +0,0 @@ -use { - base64::{prelude::BASE64_STANDARD, DecodeError, Engine}, - chacha20poly1305::{ - aead::{Aead, KeyInit, OsRng, Payload}, - AeadCore, - ChaCha20Poly1305, - Nonce, - }, - std::string::FromUtf8Error, -}; - -// 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 INIT_VEC_LEN: usize = 12; -const PUB_KEY_LENGTH: usize = 32; -const SYM_KEY_LENGTH: usize = 32; - -pub type InitVec = [u8; INIT_VEC_LEN]; -pub type SymKey = [u8; SYM_KEY_LENGTH]; -pub type PubKey = [u8; PUB_KEY_LENGTH]; - -/// Payload encoding, decoding, encryption and decryption errors. -#[derive(Debug, thiserror::Error)] -pub enum PayloadError { - #[error("Payload is not base64 encoded")] - Base64Decode(#[from] DecodeError), - #[error("Payload decryption failure: {0}")] - Decryption(String), - #[error("Payload encryption failure: {0}")] - Encryption(String), - #[error("Invalid Initialization Vector length={0}")] - InitVecLen(usize), - #[error("Invalid symmetrical key length={0}")] - SymKeyLen(usize), - #[error("Payload does not fit initialization vector (index: {0}..{1})")] - ParseInitVecLen(usize, usize), - #[error("Payload does not fit sender public key (index: {0}..{1})")] - ParseSenderPublicKeyLen(usize, usize), - #[error("Payload is not a valid JSON encoding")] - PayloadJson(#[from] FromUtf8Error), - #[error("Unsupported envelope type={0}")] - UnsupportedEnvelopeType(u8), - #[error("Unexpected envelope type={0}, expected={1}")] - UnexpectedEnvelopeType(u8, u8), -} - -#[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. - init_vec: &'a InitVec, - 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 init_vec_start_index: usize = TYPE_INDEX + TYPE_LENGTH; - let init_vec_end_index: usize = init_vec_start_index + INIT_VEC_LEN; - let sealed_start_index: usize = init_vec_end_index; - Ok(EncodingParams { - init_vec: data[init_vec_start_index..init_vec_end_index] - .try_into() - .map_err(|_| { - PayloadError::ParseInitVecLen(init_vec_start_index, init_vec_end_index) - })?, - sealed: &data[sealed_start_index..], - envelope_type: EnvelopeType::Type0, - }) - } - TYPE_1 => { - let key_start_index: usize = TYPE_INDEX + TYPE_LENGTH; - let key_end_index: usize = key_start_index + PUB_KEY_LENGTH; - let init_vec_start_index: usize = key_end_index; - let init_vec_end_index: usize = init_vec_start_index + INIT_VEC_LEN; - let sealed_start_index: usize = init_vec_end_index; - let init_vec = data[init_vec_start_index..init_vec_end_index] - .try_into() - .map_err(|_| { - PayloadError::ParseInitVecLen(init_vec_start_index, init_vec_end_index) - })?; - let envelope_type = EnvelopeType::Type1 { - sender_public_key: &data[sealed_start_index..key_end_index].try_into().map_err( - |_| { - PayloadError::ParseSenderPublicKeyLen( - init_vec_start_index, - init_vec_end_index, - ) - }, - ), - }; - - Ok(EncodingParams { - envelope_type, - init_vec, - sealed: &data[sealed_start_index..], - }) - } - _ => Err(PayloadError::UnsupportedEnvelopeType(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() - .map_err(|_| PayloadError::InitVecLen(nonce.len()))?, - )) -} - -/// 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 { - return Err(PayloadError::UnexpectedEnvelopeType(TYPE_1, TYPE_0)); - } - - let payload = Payload { - msg: decoded.sealed, - aad: &[], - }; - let decrypted = decrypt( - decoded - .init_vec - .try_into() - .map_err(|_| PayloadError::InitVecLen(decoded.init_vec.len()))?, - payload, - key, - )?; - - Ok(String::from_utf8(decrypted)?) -} - -fn encrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result, PayloadError> { - let cipher = ChaCha20Poly1305::new( - key.try_into() - .map_err(|_| PayloadError::SymKeyLen(key.len()))?, - ); - let sealed = cipher - .encrypt(nonce, payload) - .map_err(|e| PayloadError::Encryption(e.to_string()))?; - - Ok(sealed) -} - -fn encode(envelope_type: EnvelopeType, sealed: &[u8], init_vec: &InitVec) -> String { - match envelope_type { - EnvelopeType::Type0 => { - BASE64_STANDARD.encode([&[TYPE_0], init_vec.as_slice(), sealed].concat()) - } - EnvelopeType::Type1 { sender_public_key } => BASE64_STANDARD - .encode([&[TYPE_1], sender_public_key.as_slice(), init_vec, sealed].concat()), - } -} - -fn decrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result, PayloadError> { - let cipher = ChaCha20Poly1305::new( - key.try_into() - .map_err(|_| PayloadError::SymKeyLen(key.len()))?, - ); - let unsealed = cipher - .decrypt(nonce, payload) - .map_err(|e| PayloadError::Decryption(e.to_string()))?; - - Ok(unsealed) -} diff --git a/pairing_api/src/lib.rs b/pairing_api/src/lib.rs deleted file mode 100644 index 8f251fb..0000000 --- a/pairing_api/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod crypto; -mod pairing_url; -mod session; diff --git a/pairing_api/src/pairing_url.rs b/pairing_api/src/pairing_url.rs deleted file mode 100644 index 2c10e09..0000000 --- a/pairing_api/src/pairing_url.rs +++ /dev/null @@ -1,190 +0,0 @@ -// topic = "7f6e504bfad60b485450578e05678ed3e8e8c4751d3c6160be17160d63ec90f9" -// version = 2 -// symKey = "587d5484ce2a2a6ee3ba1962fdd7e8588e06200c46823bd18fbd67def96ad303" -// methods = [wc_sessionPropose],[wc_authRequest,wc_authBatchRequest] -// relay = { protocol: "irn", data: "" } -// Required - -// symKey (STRING) = symmetric key used for pairing encryption -// methods (STRING) = comma separated array of inner arrays of methods. Inner -// arrays are grouped by ProtocolType relay-protocol (STRING) = protocol name -// used for relay Optional - -// relay-data (STRING) = hex data payload used for relay -// expiryTimestamp (UINT) = unixr timestamp in seconds - after the timestamp the -// pairing is considered expired, should be generated 5 minutes in the future - -use { - lazy_static::lazy_static, - regex::Regex, - std::{collections::HashMap, str::FromStr}, - thiserror::Error, - url::Url, -}; - -lazy_static! { - static ref TOPIC_VERSION_REGEX: Regex = - Regex::new(r"^(?P[[:word:]-]+)@(?P\d+)$").expect("Failed to compile regex"); -} - -#[derive(PartialEq, Eq)] -pub struct PairingParams { - pub sym_key: Vec, - pub relay_protocol: String, - pub relay_data: Option, - pub expiry_timestamp: Option, -} - -#[derive(PartialEq, Eq)] -pub struct Pairing { - pub topic: String, - pub version: String, - pub params: PairingParams, -} - -impl std::fmt::Debug for Pairing { - /// Debug with key masked. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("WCPairingUrl") - .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).map_err(|err| ParseError::InvalidData(err.to_string()))?; - if url.scheme() != "wc" { - return Result::Err(ParseError::UnexpectedProtocol(url.scheme().to_owned())); - } - - let (topic, version) = Self::try_topic_and_version_from_path(url.path())?; - let params = Self::try_params_from_url(&url)?; - - Ok(Self { - topic, - version, - params, - }) - } -} - -impl Pairing { - fn try_topic_and_version_from_path(path: &str) -> Result<(String, String), ParseError> { - let caps = TOPIC_VERSION_REGEX - .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)) - } - - /// Try to parse WalletConnect pairing url - fn try_params_from_url(url: &Url) -> Result { - let mut params = HashMap::new(); - let queries = url.query_pairs(); - - for (key, value) in queries { - if let Some(existing) = params.insert(key.to_string(), value.to_string()) { - return Err(ParseError::UnexpectedParameter(key.into_owned(), existing)); - } - } - - let relay_protocol = params - .remove("relay-protocol") - .ok_or(ParseError::RelayProtocolNotFound)?; - - let sym_key = params - .remove("symKey") - .ok_or(ParseError::KeyNotFound) - .and_then(|key| hex::decode(key).map_err(ParseError::InvalidSymKey))?; - - let relay_data = params.remove("relay-data"); - let expiry_timestamp = params - .remove("expiryTimestamp") - .and_then(|t| t.parse::().ok()); - - if !params.is_empty() { - let (key, value) = params.iter().next().unwrap(); - return Err(ParseError::UnexpectedParameter(key.clone(), value.clone())); - } - - Ok(PairingParams { - relay_protocol, - sym_key, - relay_data, - expiry_timestamp, - }) - } -} - -#[derive(Error, Debug)] -pub enum ParseError { - #[error("Invalid topic and version format")] - InvalidTopicAndVersion, - #[error("Topic not found")] - TopicNotFound, - #[error("Version not found")] - VersionNotFound, - #[error("Relay protocol not found")] - RelayProtocolNotFound, - #[error("Symmetric key not found")] - KeyNotFound, - #[error("Invalid symmetric key: {0}")] - InvalidSymKey(#[from] hex::FromHexError), - #[error("Invalid data: {0}")] - InvalidData(String), - #[error("Unexpected parameter: {0} = {1}")] - UnexpectedParameter(String, String), - #[error("Unexpected protocol: {0}")] - UnexpectedProtocol(String), -} - -mod tests { - use super::*; - - #[test] - fn parse_uri() { - let uri = "wc:c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168@2?\ - relay-protocol=irn&\ - symKey=7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b"; - - let actual = Pairing { - topic: "c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168".to_owned(), - version: "2".to_owned(), - params: PairingParams { - relay_protocol: "irn".to_owned(), - sym_key: hex::decode( - "7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b", - ) - .unwrap() - .into(), - relay_data: None, - expiry_timestamp: None, - }, - }; - let expected = Pairing::from_str(uri).unwrap(); - - assert_eq!(actual, expected); - } -} diff --git a/pairing_api/src/session.rs b/pairing_api/src/session.rs deleted file mode 100644 index d4c3fac..0000000 --- a/pairing_api/src/session.rs +++ /dev/null @@ -1,77 +0,0 @@ -use { - std::fmt::Debug, - x25519_dalek::{EphemeralSecret, PublicKey}, -}; - -mod propose; - -enum WcSessionKind { - SessionProposal, - SessionRequest, - SessionUpdate, - SessionDelete, - SessionEvent, - SessionPing, - SessiongExpire, - SessionExtend, - ProposalExpire, -} - -struct SessionKey { - sym_key: [u8; 32], - public_key: PublicKey, -} - -impl std::fmt::Debug for SessionKey { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SessionKey") - .field("sym_key", &"*******") - .field("public_key", &self.public_key) - .finish() - } -} - -impl SessionKey { - /// Creates new session key from `osrng`. - 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| SessionError::SymKeyGeneration(e.to_string()))?; - - 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/pairing_api/src/session/propose.rs b/pairing_api/src/session/propose.rs deleted file mode 100644 index 8b13789..0000000 --- a/pairing_api/src/session/propose.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/relay_rpc/Cargo.toml b/relay_rpc/Cargo.toml index c9ff0d7..fb6aa17 100644 --- a/relay_rpc/Cargo.toml +++ b/relay_rpc/Cargo.toml @@ -17,7 +17,7 @@ cacao = [ "dep:alloy-sol-types", "dep:alloy-primitives", "dep:alloy-node-bindings", - "dep:alloy-contract" + "dep:alloy-contract", ] [dependencies] @@ -29,8 +29,8 @@ derive_more = { version = "0.99", default-features = false, features = [ "as_ref", "as_mut", ] } -serde = { version = "1.0", features = ["derive", "rc"] } serde-aux = { version = "4.1", default-features = false } +serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" thiserror = "1.0" ed25519-dalek = { version = "2.1.1", features = ["rand_core"] } From f16387e40620ed97cc1ed30b3133fb9ced825ee4 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 27 Aug 2024 15:04:52 +0100 Subject: [PATCH 04/18] save dev state --- sign_api/Cargo.toml | 21 + sign_api/src/crypto.rs | 190 ++++++ sign_api/src/lib.rs | 8 + sign_api/src/pairing_uri.rs | 188 ++++++ sign_api/src/rpc.rs | 2 + sign_api/src/rpc/params.rs | 20 + sign_api/src/rpc/params/session.rs | 727 +++++++++++++++++++++ sign_api/src/rpc/params/session/propose.rs | 90 +++ sign_api/src/session.rs | 73 +++ 9 files changed, 1319 insertions(+) create mode 100644 sign_api/Cargo.toml create mode 100644 sign_api/src/crypto.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.rs create mode 100644 sign_api/src/rpc/params/session/propose.rs create mode 100644 sign_api/src/session.rs diff --git a/sign_api/Cargo.toml b/sign_api/Cargo.toml new file mode 100644 index 0000000..448d1b5 --- /dev/null +++ b/sign_api/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "sign_api" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.86" +base64 = "0.21.2" +chacha20poly1305 = "0.10" +hex = "0.4.2" +hkdf = "0.12.4" +lazy_static = "1.4" +paste = "1.0.15" +regex = "1.10.6" +sha2 = { version = "0.10.6" } +serde = { version = "1.0", features = ["derive", "rc"] } +serde_json = "1.0" +thiserror = "1.0" +url = "2.3" +x25519-dalek = { version = "2.0", features = ["static_secrets"] } +rand = { version = "0.8" } diff --git a/sign_api/src/crypto.rs b/sign_api/src/crypto.rs new file mode 100644 index 0000000..da59750 --- /dev/null +++ b/sign_api/src/crypto.rs @@ -0,0 +1,190 @@ +use { + base64::{prelude::BASE64_STANDARD, DecodeError, Engine}, + chacha20poly1305::{ + aead::{Aead, AeadCore, KeyInit, OsRng, Payload}, + ChaCha20Poly1305, + Nonce, + }, + std::string::FromUtf8Error, +}; + +// 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 INIT_VEC_LEN: usize = 12; +const PUB_KEY_LENGTH: usize = 32; +const SYM_KEY_LENGTH: usize = 32; + +pub type InitVec = [u8; INIT_VEC_LEN]; +pub type SymKey = [u8; SYM_KEY_LENGTH]; +pub type PubKey = [u8; PUB_KEY_LENGTH]; + +/// Payload encoding, decoding, encryption and decryption errors. +#[derive(Debug, thiserror::Error)] +pub enum PayloadError { + #[error("Payload is not base64 encoded")] + Base64Decode(#[from] DecodeError), + #[error("Payload decryption failure: {0}")] + Decryption(String), + #[error("Payload encryption failure: {0}")] + Encryption(String), + #[error("Invalid Initialization Vector length={0}")] + InitVecLen(usize), + #[error("Invalid symmetrical key length={0}")] + SymKeyLen(usize), + #[error("Payload does not fit initialization vector (index: {0}..{1})")] + ParseInitVecLen(usize, usize), + #[error("Payload does not fit sender public key (index: {0}..{1})")] + ParseSenderPublicKeyLen(usize, usize), + #[error("Payload is not a valid JSON encoding")] + PayloadJson(#[from] FromUtf8Error), + #[error("Unsupported envelope type={0}")] + UnsupportedEnvelopeType(u8), + #[error("Unexpected envelope type={0}, expected={1}")] + UnexpectedEnvelopeType(u8, u8), +} + +#[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. + init_vec: &'a InitVec, + 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 init_vec_start_index: usize = TYPE_INDEX + TYPE_LENGTH; + let init_vec_end_index: usize = init_vec_start_index + INIT_VEC_LEN; + let sealed_start_index: usize = init_vec_end_index; + Ok(EncodingParams { + init_vec: data[init_vec_start_index..init_vec_end_index] + .try_into() + .map_err(|_| { + PayloadError::ParseInitVecLen(init_vec_start_index, init_vec_end_index) + })?, + sealed: &data[sealed_start_index..], + envelope_type: EnvelopeType::Type0, + }) + } + TYPE_1 => { + let key_start_index: usize = TYPE_INDEX + TYPE_LENGTH; + let key_end_index: usize = key_start_index + PUB_KEY_LENGTH; + let init_vec_start_index: usize = key_end_index; + let init_vec_end_index: usize = init_vec_start_index + INIT_VEC_LEN; + let sealed_start_index: usize = init_vec_end_index; + let init_vec = data[init_vec_start_index..init_vec_end_index] + .try_into() + .map_err(|_| { + PayloadError::ParseInitVecLen(init_vec_start_index, init_vec_end_index) + })?; + + Ok(EncodingParams { + envelope_type: EnvelopeType::Type1 { + sender_public_key: data[sealed_start_index..key_end_index] + .try_into() + .map_err(|_| { + PayloadError::ParseSenderPublicKeyLen( + init_vec_start_index, + init_vec_end_index, + ) + })?, + }, + init_vec, + sealed: &data[sealed_start_index..], + }) + } + _ => Err(PayloadError::UnsupportedEnvelopeType(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() + .map_err(|_| PayloadError::InitVecLen(nonce.len()))?, + )) +} + +/// 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 { + return Err(PayloadError::UnexpectedEnvelopeType(TYPE_1, TYPE_0)); + } + + let payload = Payload { + msg: decoded.sealed, + aad: &[], + }; + let decrypted = decrypt(decoded.init_vec.into(), payload, key)?; + + Ok(String::from_utf8(decrypted)?) +} + +fn encrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result, PayloadError> { + let cipher = ChaCha20Poly1305::new(key.into()); + let sealed = cipher + .encrypt(nonce, payload) + .map_err(|e| PayloadError::Encryption(e.to_string()))?; + + Ok(sealed) +} + +fn encode(envelope_type: EnvelopeType, sealed: &[u8], init_vec: &InitVec) -> String { + match envelope_type { + EnvelopeType::Type0 => { + BASE64_STANDARD.encode([&[TYPE_0], init_vec.as_slice(), sealed].concat()) + } + EnvelopeType::Type1 { sender_public_key } => BASE64_STANDARD + .encode([&[TYPE_1], sender_public_key.as_slice(), init_vec, sealed].concat()), + } +} + +fn decrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result, PayloadError> { + let cipher = ChaCha20Poly1305::new(key.into()); + let unsealed = cipher + .decrypt(nonce, payload) + .map_err(|e| PayloadError::Decryption(e.to_string()))?; + + Ok(unsealed) +} diff --git a/sign_api/src/lib.rs b/sign_api/src/lib.rs new file mode 100644 index 0000000..84c423c --- /dev/null +++ b/sign_api/src/lib.rs @@ -0,0 +1,8 @@ +pub mod crypto; +mod pairing_uri; +pub mod rpc; +pub mod session; + +pub use pairing_uri::{Pairing, PairingParams}; +pub use crypto::*; +pub use rpc::*; diff --git a/sign_api/src/pairing_uri.rs b/sign_api/src/pairing_uri.rs new file mode 100644 index 0000000..6993f1a --- /dev/null +++ b/sign_api/src/pairing_uri.rs @@ -0,0 +1,188 @@ +// topic = "7f6e504bfad60b485450578e05678ed3e8e8c4751d3c6160be17160d63ec90f9" +// version = 2 +// symKey = "587d5484ce2a2a6ee3ba1962fdd7e8588e06200c46823bd18fbd67def96ad303" +// methods = [wc_sessionPropose],[wc_authRequest,wc_authBatchRequest] +// relay = { protocol: "irn", data: "" } +// Required + +// symKey (STRING) = symmetric key used for pairing encryption +// methods (STRING) = comma separated array of inner arrays of methods. Inner +// arrays are grouped by ProtocolType relay-protocol (STRING) = protocol name +// used for relay Optional + +// relay-data (STRING) = hex data payload used for relay +// expiryTimestamp (UINT) = unixr timestamp in seconds - after the timestamp the +// pairing is considered expired, should be generated 5 minutes in the future + +use { + lazy_static::lazy_static, + regex::Regex, + std::{collections::HashMap, str::FromStr}, + thiserror::Error, + url::Url, +}; + +lazy_static! { + static ref TOPIC_VERSION_REGEX: Regex = + Regex::new(r"^(?P[[:word:]-]+)@(?P\d+)$").expect("Failed to compile regex"); +} + +#[derive(PartialEq, Eq)] +pub struct PairingParams { + pub sym_key: Vec, + pub relay_protocol: String, + pub relay_data: Option, + pub expiry_timestamp: Option, +} + +#[derive(PartialEq, Eq)] +pub struct Pairing { + pub topic: String, + pub version: String, + pub params: PairingParams, +} + +impl std::fmt::Debug for Pairing { + /// Debug with key masked. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WCPairingUrl") + .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).map_err(|err| ParseError::InvalidData(err.to_string()))?; + if url.scheme() != "wc" { + return Result::Err(ParseError::UnexpectedProtocol(url.scheme().to_owned())); + } + + let (topic, version) = Self::try_topic_and_version_from_path(url.path())?; + let params = Self::try_params_from_url(&url)?; + + Ok(Self { + topic, + version, + params, + }) + } +} + +impl Pairing { + fn try_topic_and_version_from_path(path: &str) -> Result<(String, String), ParseError> { + let caps = TOPIC_VERSION_REGEX + .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)) + } + + /// Try to parse WalletConnect pairing url + fn try_params_from_url(url: &Url) -> Result { + let mut params = HashMap::new(); + let queries = url.query_pairs(); + + for (key, value) in queries { + if let Some(existing) = params.insert(key.to_string(), value.to_string()) { + return Err(ParseError::UnexpectedParameter(key.into_owned(), existing)); + } + } + + let relay_protocol = params + .remove("relay-protocol") + .ok_or(ParseError::RelayProtocolNotFound)?; + + let sym_key = params + .remove("symKey") + .ok_or(ParseError::KeyNotFound) + .and_then(|key| hex::decode(key).map_err(ParseError::InvalidSymKey))?; + + let relay_data = params.remove("relay-data"); + let expiry_timestamp = params + .remove("expiryTimestamp") + .and_then(|t| t.parse::().ok()); + + if !params.is_empty() { + let (key, value) = params.iter().next().unwrap(); + return Err(ParseError::UnexpectedParameter(key.clone(), value.clone())); + } + + Ok(PairingParams { + relay_protocol, + sym_key, + relay_data, + expiry_timestamp, + }) + } +} + +#[derive(Error, Debug)] +pub enum ParseError { + #[error("Invalid topic and version format")] + InvalidTopicAndVersion, + #[error("Topic not found")] + TopicNotFound, + #[error("Version not found")] + VersionNotFound, + #[error("Relay protocol not found")] + RelayProtocolNotFound, + #[error("Symmetric key not found")] + KeyNotFound, + #[error("Invalid symmetric key: {0}")] + InvalidSymKey(#[from] hex::FromHexError), + #[error("Invalid data: {0}")] + InvalidData(String), + #[error("Unexpected parameter: {0} = {1}")] + UnexpectedParameter(String, String), + #[error("Unexpected protocol: {0}")] + UnexpectedProtocol(String), +} + +mod tests { + #[test] + fn parse_uri() { + let uri = "wc:c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168@2?\ + relay-protocol=irn&\ + symKey=7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b"; + + let actual = Pairing { + topic: "c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168".to_owned(), + version: "2".to_owned(), + params: PairingParams { + relay_protocol: "irn".to_owned(), + sym_key: hex::decode( + "7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b", + ) + .unwrap() + .into(), + relay_data: None, + expiry_timestamp: 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..bb02d5f --- /dev/null +++ b/sign_api/src/rpc.rs @@ -0,0 +1,2 @@ +mod params; +pub use params::*; diff --git a/sign_api/src/rpc/params.rs b/sign_api/src/rpc/params.rs new file mode 100644 index 0000000..21c7800 --- /dev/null +++ b/sign_api/src/rpc/params.rs @@ -0,0 +1,20 @@ +pub mod session; + +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, +} diff --git a/sign_api/src/rpc/params/session.rs b/sign_api/src/rpc/params/session.rs new file mode 100644 index 0000000..6c2af4e --- /dev/null +++ b/sign_api/src/rpc/params/session.rs @@ -0,0 +1,727 @@ +mod propose; + +use { + paste::paste, + propose::{SessionProposeRequest, SessionProposeResponse}, + regex::Regex, + serde::{Deserialize, Serialize}, + serde_json::Value, + std::{ + collections::{BTreeMap, BTreeSet}, + ops::Deref, + sync::OnceLock, + }, +}; + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces +/// +/// https://chainagnostic.org/CAIPs/caip-2 +/// +/// chain_id: namespace + ":" + reference +/// namespace: [-a-z0-9]{3,8} +/// reference: [-_a-zA-Z0-9]{1,32} +static CAIP2_REGEX: OnceLock = OnceLock::new(); +fn get_caip2_regex() -> &'static Regex { + CAIP2_REGEX.get_or_init(|| { + Regex::new(r"^(?P[-[:alnum:]]{3,8})((?::)(?P[-_[:alnum:]]{1,32}))?$") + .expect("invalid regex: unexpected error") + }) +} + +/// Errors covering namespace validation errors. +/// +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces +/// and some additional variants. +#[derive(Debug, thiserror::Error, Eq, PartialEq)] +pub enum ProposeNamespaceError { + #[error("Required chains are not supported: {0}")] + UnsupportedChains(String), + #[error("Chains must not be empty")] + UnsupportedChainsEmpty, + #[error("Chains must be CAIP-2 compliant: {0}")] + UnsupportedChainsCaip2(String), + #[error("Chains must be defined in matching namespace: expected={0}, actual={1}")] + UnsupportedChainsNamespace(String, String), + #[error("Required events are not supported: {0}")] + UnsupportedEvents(String), + #[error("Required methods are not supported: {0}")] + UnsupportedMethods(String), + #[error("Required namespace is not supported: {0}")] + UnsupportedNamespace(String), + #[error("Namespace formatting must match CAIP-2: {0}")] + UnsupportedNamespaceKey(String), +} + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# +/// proposal-namespace +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct ProposeNamespace { + pub chains: BTreeSet, + pub methods: BTreeSet, + pub events: BTreeSet, +} + +impl ProposeNamespace { + fn supported(&self, required: &Self) -> Result<(), ProposeNamespaceError> { + let join_err = |required: &BTreeSet, ours: &BTreeSet| -> String { + return required + .difference(ours) + .map(|s| s.as_str()) + .collect::>() + .join(","); + }; + + // validate chains + if !self.chains.is_superset(&required.chains) { + return Err(ProposeNamespaceError::UnsupportedChains(join_err( + &required.chains, + &self.chains, + ))); + } + + // validate methods + if !self.methods.is_superset(&required.methods) { + return Err(ProposeNamespaceError::UnsupportedMethods(join_err( + &required.methods, + &self.methods, + ))); + } + + // validate events + if !self.events.is_superset(&required.events) { + return Err(ProposeNamespaceError::UnsupportedEvents(join_err( + &required.events, + &self.events, + ))); + } + + Ok(()) + } + + pub fn chains_caip2_validate( + &self, + namespace: &str, + reference: Option<&str>, + ) -> Result<(), ProposeNamespaceError> { + // https://specs.walletconnect.com/2.0/specs/clients/sign/ + // namespaces#13-chains-might-be-omitted-if-the-caip-2-is-defined-in-the-index + match (reference, self.chains.is_empty()) { + (None, true) => return Err(ProposeNamespaceError::UnsupportedChainsEmpty), + (Some(_), true) => return Ok(()), + _ => {} + } + + let caip_regex = get_caip2_regex(); + for chain in self.chains.iter() { + let captures = caip_regex + .captures(chain) + .ok_or_else(|| ProposeNamespaceError::UnsupportedChainsCaip2(chain.to_string()))?; + + let chain_namespace = captures + .name("namespace") + .expect("chain namespace name is missing: unexpected error") + .as_str(); + + if namespace != chain_namespace { + return Err(ProposeNamespaceError::UnsupportedChainsNamespace( + namespace.to_string(), + chain_namespace.to_string(), + )); + } + + let chain_reference = + captures + .name("reference") + .map(|m| m.as_str()) + .ok_or_else(|| { + ProposeNamespaceError::UnsupportedChainsCaip2(namespace.to_string()) + })?; + + if let Some(r) = reference { + if r != chain_reference { + return Err(ProposeNamespaceError::UnsupportedChainsCaip2( + namespace.to_string(), + )); + } + } + } + + Ok(()) + } +} + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces +#[derive(Debug, Serialize, Eq, PartialEq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct ProposeNamespaces(pub BTreeMap); + +impl Deref for ProposeNamespaces { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl ProposeNamespaces { + /// Ensures that application is compatible with the requester requirements. + /// + /// Implementation must support at least all the elements in `required`. + pub fn supported(&self, required: &ProposeNamespaces) -> Result<(), ProposeNamespaceError> { + if self.is_empty() { + return Err(ProposeNamespaceError::UnsupportedNamespace( + "None supported".to_string(), + )); + } + + if required.is_empty() { + return Ok(()); + } + + for (name, other) in required.iter() { + let ours = self + .get(name) + .ok_or_else(|| ProposeNamespaceError::UnsupportedNamespace(name.to_string()))?; + ours.supported(other)?; + } + + Ok(()) + } + + pub fn caip2_validate(&self) -> Result<(), ProposeNamespaceError> { + let caip_regex = get_caip2_regex(); + for (name, namespace) in self.deref() { + let captures = caip_regex + .captures(name) + .ok_or_else(|| ProposeNamespaceError::UnsupportedNamespaceKey(name.to_string()))?; + + let name = captures + .name("namespace") + .expect("namespace name missing: unexpected error") + .as_str(); + + let reference = captures.name("reference").map(|m| m.as_str()); + + namespace.chains_caip2_validate(name, reference)?; + } + + Ok(()) + } +} + +/// Errors covering Sign API payload parameter conversion issues. +#[derive(Debug, thiserror::Error)] +pub enum ParamsError { + /// Sign API serialization/deserialization issues. + #[error("Failure serializing/deserializing Sign API parameters: {0}")] + Serde(#[from] serde_json::Error), + /// Sign API invalid response tag. + #[error("Response tag={0} does not match any of the Sign API methods")] + ResponseTag(u32), +} + +/// 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(_) => propose::[], + } + } + } + } + } +} + +// 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 == propose::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionPropose(serde_json::from_value(value)?)) + } else { + Err(ParamsError::ResponseTag(tag)) + } + } + } + } + }; +} + +/// 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 = ParamsError; + + 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 = ParamsError; + + fn try_from(value: ResponseParamsError) -> Result { + Ok(Self::Err(serde_json::to_value(value)?)) + } +} + +#[cfg(test)] +mod tests { + use {super::*, anyhow::Result, serde::de::DeserializeOwned, serde_json}; + + // ======================================================================================================== + // https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + // rejecting-a-session-response + // - validates namespaces match at least all requiredNamespaces + // ======================================================================================================== + + fn test_namespace() -> ProposeNamespace { + let test_vec = vec![ + "0".to_string(), + "1".to_string(), + "2".to_string(), + "3".to_string(), + "4".to_string(), + ]; + ProposeNamespace { + chains: BTreeSet::from_iter(test_vec.clone()), + methods: BTreeSet::from_iter(test_vec.clone()), + events: BTreeSet::from_iter(test_vec.clone()), + } + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 19-proposal-namespaces-may-be-empty + #[test] + fn namespaces_required_empty_success() { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("1".to_string(), ProposeNamespace { + ..Default::default() + }); + map + }); + assert!(namespaces + .supported(&ProposeNamespaces( + BTreeMap::::new() + )) + .is_ok()) + } + + #[test] + fn namespace_unsupported_chains_failure() { + let theirs = test_namespace(); + let mut ours = test_namespace(); + + ours.chains.remove("1"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedChains("1".to_string())), + ); + + ours.chains.remove("2"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedChains("1,2".to_string())), + ); + } + + #[test] + fn namespace_unsupported_methods_failure() { + let theirs = test_namespace(); + let mut ours = test_namespace(); + + ours.methods.remove("1"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedMethods("1".to_string())), + ); + + ours.methods.remove("2"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedMethods("1,2".to_string())), + ); + } + + #[test] + fn namespace_unsupported_events_failure() { + let theirs = test_namespace(); + let mut ours = test_namespace(); + + ours.events.remove("1"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedEvents("1".to_string())), + ); + + ours.events.remove("2"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedEvents("1,2".to_string())), + ); + } + + // ======================================================================================================== + // CAIP-2 TESTS: https://chainagnostic.org/CAIPs/caip-2 + // ======================================================================================================== + #[test] + fn caip2_test_cases() -> Result<(), ProposeNamespaceError> { + let chains = [ + // Ethereum mainnet + "eip155:1", + // Bitcoin mainnet (see https://github.com/bitcoin/bips/blob/master/bip-0122.mediawiki#definition-of-chain-id) + "bip122:000000000019d6689c085ae165831e93", + // Litecoin + "bip122:12a765e31ffd4059bada1e25190f6e98", + // Feathercoin (Litecoin fork) + "bip122:fdbe99b90c90bae7505796461471d89a", + // Cosmos Hub (Tendermint + Cosmos SDK) + "cosmos:cosmoshub-2", + "cosmos:cosmoshub-3", + // Binance chain (Tendermint + Cosmos SDK; see https://dataseed5.defibit.io/genesis) + "cosmos:Binance-Chain-Tigris", + // IOV Mainnet (Tendermint + weave) + "cosmos:iov-mainnet", + // StarkNet Testnet + "starknet:SN_GOERLI", + // Lisk Mainnet (LIP-0009; see https://github.com/LiskHQ/lips/blob/master/proposals/lip-0009.md) + "lip9:9ee11e9df416b18b", + // Dummy max length (8+1+32 = 41 chars/bytes) + "chainstd:8c3444cf8970a9e41a706fab93e7a6c4", + ]; + + let caip2_regex = get_caip2_regex(); + for chain in chains { + caip2_regex + .captures(chain) + .ok_or_else(|| ProposeNamespaceError::UnsupportedChainsCaip2(chain.to_string()))?; + } + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 12-proposal-namespaces-must-not-have-chains-empty + #[test] + fn caip2_12_chains_empty_failure() { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155".to_string(), ProposeNamespace { + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedChainsEmpty), + ); + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 13-chains-might-be-omitted-if-the-caip-2-is-defined-in-the-index + #[test] + fn caip2_13_chains_omitted_success() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155:1".to_string(), ProposeNamespace { + ..Default::default() + }); + map + }); + + namespaces.caip2_validate()?; + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 14-chains-must-be-caip-2-compliant + #[test] + fn caip2_14_must_be_compliant_failure() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["1".to_string()]), + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedChainsCaip2( + "1".to_string() + )), + ); + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 16-all-chains-in-the-namespace-must-contain-the-namespace-prefix + #[test] + fn caip2_16_chain_prefix_success() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["eip155:1".to_string()]), + ..Default::default() + }); + map.insert("bip122".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec![ + "bip122:000000000019d6689c085ae165831e93".to_string(), + "bip122:12a765e31ffd4059bada1e25190f6e98".to_string(), + ]), + ..Default::default() + }); + map.insert("cosmos".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec![ + "cosmos:cosmoshub-2".to_string(), + "cosmos:cosmoshub-3".to_string(), + "cosmos:Binance-Chain-Tigris".to_string(), + "cosmos:iov-mainnet".to_string(), + ]), + ..Default::default() + }); + map.insert("starknet".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["starknet:SN_GOERLI".to_string()]), + ..Default::default() + }); + map.insert("chainstd".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec![ + "chainstd:8c3444cf8970a9e41a706fab93e7a6c4".to_string() + ]), + ..Default::default() + }); + map + }); + + namespaces.caip2_validate()?; + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 16-all-chains-in-the-namespace-must-contain-the-namespace-prefix + #[test] + fn caip2_16_chain_prefix_failure() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["cosmos:1".to_string()]), + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedChainsNamespace( + "eip155".to_string(), + "cosmos".to_string() + )), + ); + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 17-namespace-key-must-comply-with-caip-2-specification + #[test] + fn caip2_17_namespace_key_failure() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec![":1".to_string()]), + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedNamespaceKey( + "".to_string() + )), + ); + + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("**".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["**:1".to_string()]), + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedNamespaceKey( + "**".to_string() + )), + ); + + Ok(()) + } + + /// Trims json of the whitespaces and newlines. + /// + /// Allows to use "pretty json" in unittest, and still get consistent + /// results post serialization/deserialization. + pub fn param_json_trim(json: &str) -> String { + json.chars() + .filter(|c| !c.is_whitespace() && *c != '\n') + .collect::() + } + + /// Tests input json serialization/deserialization into the specified type. + pub fn param_serde_test(json: &str) -> Result<()> + where + T: Serialize + DeserializeOwned, + { + let expected = param_json_trim(json); + let deserialized: T = serde_json::from_str(&expected)?; + let actual = serde_json::to_string(&deserialized)?; + + assert_eq!(expected, actual); + + Ok(()) + } +} 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..8ec7ed7 --- /dev/null +++ b/sign_api/src/rpc/params/session/propose.rs @@ -0,0 +1,90 @@ +use { + super::{IrnMetadata, ProposeNamespaces}, + crate::rpc::{Metadata, Relay}, + serde::{Deserialize, Serialize}, +}; + +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: ProposeNamespaces, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionProposeResponse { + pub relay: Relay, + pub responder_public_key: String, +} + +#[cfg(test)] +mod tests { + use super::{super::tests::param_serde_test, *}; + + #[test] + fn test_serde_session_propose_request() { + // https://specs.walletconnect.com/2.0/specs/clients/sign/ + // session-events#session_propose + let json = r#" + { + "relays": [ + { + "protocol": "irn" + } + ], + "proposer": { + "publicKey": "a3ad5e26070ddb2809200c6f56e739333512015bceeadbb8ea1731c4c7ddb207", + "metadata": { + "description": "React App for WalletConnect", + "url": "http://localhost:3000", + "icons": [ + "https://avatars.githubusercontent.com/u/37784886" + ], + "name": "React App" + } + }, + "requiredNamespaces": { + "eip155": { + "chains": [ + "eip155:5" + ], + "methods": [ + "eth_sendTransaction", + "eth_sign", + "eth_signTransaction", + "eth_signTypedData", + "personal_sign" + ], + "events": [ + "accountsChanged", + "chainChanged" + ] + } + } + } + "#; + + param_serde_test::(json); + } +} diff --git a/sign_api/src/session.rs b/sign_api/src/session.rs new file mode 100644 index 0000000..26a9341 --- /dev/null +++ b/sign_api/src/session.rs @@ -0,0 +1,73 @@ +use { + hkdf::Hkdf, + rand::{rngs::OsRng, CryptoRng, RngCore}, + sha2::{Digest, Sha256}, + std::fmt::Debug, + x25519_dalek::{EphemeralSecret, PublicKey}, +}; + +/// Session key and topic derivation errors. +#[derive(Debug, thiserror::Error)] +pub enum SessionError { + #[error("Failed to generate symmetric session key: {0}")] + SymKeyGeneration(String), +} + +pub struct SessionKey { + sym_key: [u8; 32], + public_key: PublicKey, +} + +impl std::fmt::Debug for SessionKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionKey") + .field("sym_key", &"*******") + .field("public_key", &self.public_key) + .finish() + } +} + +impl SessionKey { + /// Creates new session key from `osrng`. + 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| SessionError::SymKeyGeneration(e.to_string()))?; + + 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()) + } +} From c3d55b69f2176967a4bede9f84ea0dc378269fe3 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 28 Aug 2024 04:58:11 +0100 Subject: [PATCH 05/18] save dev state --- relay_client/src/websocket.rs | 2 +- relay_rpc/Cargo.toml | 2 + relay_rpc/src/rpc.rs | 37 ++ sign_api/Cargo.toml | 17 + sign_api/src/lib.rs | 8 +- sign_api/src/pairing_uri.rs | 9 +- sign_api/src/rpc.rs | 2 - sign_api/src/rpc/params.rs | 20 - sign_api/src/rpc/params/session.rs | 727 --------------------- sign_api/src/rpc/params/session/propose.rs | 90 --- 10 files changed, 69 insertions(+), 845 deletions(-) delete mode 100644 sign_api/src/rpc.rs delete mode 100644 sign_api/src/rpc/params.rs delete mode 100644 sign_api/src/rpc/params/session.rs delete mode 100644 sign_api/src/rpc/params/session/propose.rs diff --git a/relay_client/src/websocket.rs b/relay_client/src/websocket.rs index 06c3008..cd59aaf 100644 --- a/relay_client/src/websocket.rs +++ b/relay_client/src/websocket.rs @@ -1,5 +1,5 @@ #[cfg(not(target_arch = "wasm32"))] -use tokio::spawn; +use tokio::task::spawn; #[cfg(target_arch = "wasm32")] use wasm_bindgen_futures::spawn_local as spawn; use { diff --git a/relay_rpc/Cargo.toml b/relay_rpc/Cargo.toml index fb6aa17..5ed910d 100644 --- a/relay_rpc/Cargo.toml +++ b/relay_rpc/Cargo.toml @@ -42,6 +42,7 @@ chrono = { version = "0.4", default-features = false, features = [ regex = "1.7" once_cell = "1.16" jsonwebtoken = "8.1" +hkdf = "0.12.4" k256 = { version = "0.13", optional = true } sha3 = { version = "0.10", optional = true } sha2 = { version = "0.10.6" } @@ -56,6 +57,7 @@ alloy-contract = { git = "https://github.com/alloy-rs/alloy.git", rev = "d68a6b7 alloy-json-abi = { version = "0.7.0", optional = true } alloy-sol-types = { version = "0.7.0", optional = true } alloy-primitives = { version = "0.7.0", optional = true } +paste = "1.0.15" strum = { version = "0.26", features = ["strum_macros", "derive"] } [dev-dependencies] diff --git a/relay_rpc/src/rpc.rs b/relay_rpc/src/rpc.rs index 751eaec..fda6902 100644 --- a/relay_rpc/src/rpc.rs +++ b/relay_rpc/src/rpc.rs @@ -3,6 +3,11 @@ use { crate::domain::{DidKey, MessageId, SubscriptionId, Topic}, + params::session::{ + propose::SessionProposeRequest, + settle::SessionSettleRequest, + RequestParams, + }, serde::{de::DeserializeOwned, Deserialize, Serialize}, std::{fmt::Debug, sync::Arc}, }; @@ -10,6 +15,7 @@ pub use {error::*, watch::*}; pub mod error; pub mod msg_id; +pub mod params; #[cfg(test)] mod tests; pub mod watch; @@ -88,6 +94,10 @@ impl Payload { Self::Response(response) => response.validate(), } } + + pub fn irn_tag_in_range(tag: u32) -> bool { + (1100..=1115).contains(&tag) + } } impl From for Payload @@ -99,6 +109,18 @@ where } } +impl From for Payload { + fn from(value: Request) -> Self { + Payload::Request(value) + } +} + +impl From for Payload { + fn from(value: Response) -> Self { + Payload::Response(value) + } +} + /// Enum representing a JSON RPC response. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] @@ -808,6 +830,20 @@ pub enum Params { /// topic the data is published for. #[serde(rename = "irn_subscription", alias = "iridium_subscription")] Subscription(Subscription), + + #[serde(rename = "wc_sessionPropose")] + SessionPropose(SessionProposeRequest), + #[serde(rename = "wc_sessionSettle")] + SessionSettle(SessionSettleRequest), +} + +impl From for Params { + fn from(value: RequestParams) -> Self { + match value { + RequestParams::SessionPropose(param) => Params::SessionPropose(param), + RequestParams::SessionSettle(param) => Params::SessionSettle(param), + } + } } /// Data structure representing a JSON RPC request. @@ -858,6 +894,7 @@ impl Request { Params::WatchRegister(params) => params.validate(), Params::WatchUnregister(params) => params.validate(), Params::Subscription(params) => params.validate(), + _ => Ok(()), } } } diff --git a/sign_api/Cargo.toml b/sign_api/Cargo.toml index 448d1b5..6184fb7 100644 --- a/sign_api/Cargo.toml +++ b/sign_api/Cargo.toml @@ -5,13 +5,20 @@ edition = "2021" [dependencies] anyhow = "1.0.86" +clap = { version = "4.4", features = ["derive"] } base64 = "0.21.2" chacha20poly1305 = "0.10" +chrono = { version = "0.4", default-features = false, features = [ + "alloc", + "std", +] } hex = "0.4.2" hkdf = "0.12.4" lazy_static = "1.4" paste = "1.0.15" regex = "1.10.6" +relay_client = { path = "../relay_client" } +relay_rpc = { path = "../relay_rpc" } sha2 = { version = "0.10.6" } serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" @@ -19,3 +26,13 @@ thiserror = "1.0" url = "2.3" x25519-dalek = { version = "2.0", features = ["static_secrets"] } rand = { version = "0.8" } +tokio = { version = "1.22", features = [ + "rt", + "rt-multi-thread", + "sync", + "macros", +] } + + +[[example]] +name = "session" diff --git a/sign_api/src/lib.rs b/sign_api/src/lib.rs index 84c423c..a37d6dd 100644 --- a/sign_api/src/lib.rs +++ b/sign_api/src/lib.rs @@ -1,8 +1,8 @@ pub mod crypto; mod pairing_uri; -pub mod rpc; pub mod session; -pub use pairing_uri::{Pairing, PairingParams}; -pub use crypto::*; -pub use rpc::*; +pub use { + crypto::*, + pairing_uri::{Pairing, PairingParams}, +}; diff --git a/sign_api/src/pairing_uri.rs b/sign_api/src/pairing_uri.rs index 6993f1a..d833131 100644 --- a/sign_api/src/pairing_uri.rs +++ b/sign_api/src/pairing_uri.rs @@ -105,11 +105,18 @@ impl Pairing { let queries = url.query_pairs(); for (key, value) in queries { - if let Some(existing) = params.insert(key.to_string(), value.to_string()) { + let sanitized_key: String = key + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-') + .collect(); + if let Some(existing) = params.insert(sanitized_key.to_string(), value.to_string()) { return Err(ParseError::UnexpectedParameter(key.into_owned(), existing)); } } + let mapp = params.keys(); + println!("{mapp:?}"); + let relay_protocol = params .remove("relay-protocol") .ok_or(ParseError::RelayProtocolNotFound)?; diff --git a/sign_api/src/rpc.rs b/sign_api/src/rpc.rs deleted file mode 100644 index bb02d5f..0000000 --- a/sign_api/src/rpc.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod params; -pub use params::*; diff --git a/sign_api/src/rpc/params.rs b/sign_api/src/rpc/params.rs deleted file mode 100644 index 21c7800..0000000 --- a/sign_api/src/rpc/params.rs +++ /dev/null @@ -1,20 +0,0 @@ -pub mod session; - -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, -} diff --git a/sign_api/src/rpc/params/session.rs b/sign_api/src/rpc/params/session.rs deleted file mode 100644 index 6c2af4e..0000000 --- a/sign_api/src/rpc/params/session.rs +++ /dev/null @@ -1,727 +0,0 @@ -mod propose; - -use { - paste::paste, - propose::{SessionProposeRequest, SessionProposeResponse}, - regex::Regex, - serde::{Deserialize, Serialize}, - serde_json::Value, - std::{ - collections::{BTreeMap, BTreeSet}, - ops::Deref, - sync::OnceLock, - }, -}; - -/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces -/// -/// https://chainagnostic.org/CAIPs/caip-2 -/// -/// chain_id: namespace + ":" + reference -/// namespace: [-a-z0-9]{3,8} -/// reference: [-_a-zA-Z0-9]{1,32} -static CAIP2_REGEX: OnceLock = OnceLock::new(); -fn get_caip2_regex() -> &'static Regex { - CAIP2_REGEX.get_or_init(|| { - Regex::new(r"^(?P[-[:alnum:]]{3,8})((?::)(?P[-_[:alnum:]]{1,32}))?$") - .expect("invalid regex: unexpected error") - }) -} - -/// Errors covering namespace validation errors. -/// -/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces -/// and some additional variants. -#[derive(Debug, thiserror::Error, Eq, PartialEq)] -pub enum ProposeNamespaceError { - #[error("Required chains are not supported: {0}")] - UnsupportedChains(String), - #[error("Chains must not be empty")] - UnsupportedChainsEmpty, - #[error("Chains must be CAIP-2 compliant: {0}")] - UnsupportedChainsCaip2(String), - #[error("Chains must be defined in matching namespace: expected={0}, actual={1}")] - UnsupportedChainsNamespace(String, String), - #[error("Required events are not supported: {0}")] - UnsupportedEvents(String), - #[error("Required methods are not supported: {0}")] - UnsupportedMethods(String), - #[error("Required namespace is not supported: {0}")] - UnsupportedNamespace(String), - #[error("Namespace formatting must match CAIP-2: {0}")] - UnsupportedNamespaceKey(String), -} - -/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# -/// proposal-namespace -#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] -#[serde(rename_all = "camelCase")] -pub struct ProposeNamespace { - pub chains: BTreeSet, - pub methods: BTreeSet, - pub events: BTreeSet, -} - -impl ProposeNamespace { - fn supported(&self, required: &Self) -> Result<(), ProposeNamespaceError> { - let join_err = |required: &BTreeSet, ours: &BTreeSet| -> String { - return required - .difference(ours) - .map(|s| s.as_str()) - .collect::>() - .join(","); - }; - - // validate chains - if !self.chains.is_superset(&required.chains) { - return Err(ProposeNamespaceError::UnsupportedChains(join_err( - &required.chains, - &self.chains, - ))); - } - - // validate methods - if !self.methods.is_superset(&required.methods) { - return Err(ProposeNamespaceError::UnsupportedMethods(join_err( - &required.methods, - &self.methods, - ))); - } - - // validate events - if !self.events.is_superset(&required.events) { - return Err(ProposeNamespaceError::UnsupportedEvents(join_err( - &required.events, - &self.events, - ))); - } - - Ok(()) - } - - pub fn chains_caip2_validate( - &self, - namespace: &str, - reference: Option<&str>, - ) -> Result<(), ProposeNamespaceError> { - // https://specs.walletconnect.com/2.0/specs/clients/sign/ - // namespaces#13-chains-might-be-omitted-if-the-caip-2-is-defined-in-the-index - match (reference, self.chains.is_empty()) { - (None, true) => return Err(ProposeNamespaceError::UnsupportedChainsEmpty), - (Some(_), true) => return Ok(()), - _ => {} - } - - let caip_regex = get_caip2_regex(); - for chain in self.chains.iter() { - let captures = caip_regex - .captures(chain) - .ok_or_else(|| ProposeNamespaceError::UnsupportedChainsCaip2(chain.to_string()))?; - - let chain_namespace = captures - .name("namespace") - .expect("chain namespace name is missing: unexpected error") - .as_str(); - - if namespace != chain_namespace { - return Err(ProposeNamespaceError::UnsupportedChainsNamespace( - namespace.to_string(), - chain_namespace.to_string(), - )); - } - - let chain_reference = - captures - .name("reference") - .map(|m| m.as_str()) - .ok_or_else(|| { - ProposeNamespaceError::UnsupportedChainsCaip2(namespace.to_string()) - })?; - - if let Some(r) = reference { - if r != chain_reference { - return Err(ProposeNamespaceError::UnsupportedChainsCaip2( - namespace.to_string(), - )); - } - } - } - - Ok(()) - } -} - -/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces -#[derive(Debug, Serialize, Eq, PartialEq, Deserialize, Clone, Default)] -#[serde(rename_all = "camelCase")] -pub struct ProposeNamespaces(pub BTreeMap); - -impl Deref for ProposeNamespaces { - type Target = BTreeMap; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl ProposeNamespaces { - /// Ensures that application is compatible with the requester requirements. - /// - /// Implementation must support at least all the elements in `required`. - pub fn supported(&self, required: &ProposeNamespaces) -> Result<(), ProposeNamespaceError> { - if self.is_empty() { - return Err(ProposeNamespaceError::UnsupportedNamespace( - "None supported".to_string(), - )); - } - - if required.is_empty() { - return Ok(()); - } - - for (name, other) in required.iter() { - let ours = self - .get(name) - .ok_or_else(|| ProposeNamespaceError::UnsupportedNamespace(name.to_string()))?; - ours.supported(other)?; - } - - Ok(()) - } - - pub fn caip2_validate(&self) -> Result<(), ProposeNamespaceError> { - let caip_regex = get_caip2_regex(); - for (name, namespace) in self.deref() { - let captures = caip_regex - .captures(name) - .ok_or_else(|| ProposeNamespaceError::UnsupportedNamespaceKey(name.to_string()))?; - - let name = captures - .name("namespace") - .expect("namespace name missing: unexpected error") - .as_str(); - - let reference = captures.name("reference").map(|m| m.as_str()); - - namespace.chains_caip2_validate(name, reference)?; - } - - Ok(()) - } -} - -/// Errors covering Sign API payload parameter conversion issues. -#[derive(Debug, thiserror::Error)] -pub enum ParamsError { - /// Sign API serialization/deserialization issues. - #[error("Failure serializing/deserializing Sign API parameters: {0}")] - Serde(#[from] serde_json::Error), - /// Sign API invalid response tag. - #[error("Response tag={0} does not match any of the Sign API methods")] - ResponseTag(u32), -} - -/// 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(_) => propose::[], - } - } - } - } - } -} - -// 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 == propose::IRN_RESPONSE_METADATA.tag { - Ok(Self::SessionPropose(serde_json::from_value(value)?)) - } else { - Err(ParamsError::ResponseTag(tag)) - } - } - } - } - }; -} - -/// 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 = ParamsError; - - 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 = ParamsError; - - fn try_from(value: ResponseParamsError) -> Result { - Ok(Self::Err(serde_json::to_value(value)?)) - } -} - -#[cfg(test)] -mod tests { - use {super::*, anyhow::Result, serde::de::DeserializeOwned, serde_json}; - - // ======================================================================================================== - // https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# - // rejecting-a-session-response - // - validates namespaces match at least all requiredNamespaces - // ======================================================================================================== - - fn test_namespace() -> ProposeNamespace { - let test_vec = vec![ - "0".to_string(), - "1".to_string(), - "2".to_string(), - "3".to_string(), - "4".to_string(), - ]; - ProposeNamespace { - chains: BTreeSet::from_iter(test_vec.clone()), - methods: BTreeSet::from_iter(test_vec.clone()), - events: BTreeSet::from_iter(test_vec.clone()), - } - } - - /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# - /// 19-proposal-namespaces-may-be-empty - #[test] - fn namespaces_required_empty_success() { - let namespaces = ProposeNamespaces({ - let mut map: BTreeMap = BTreeMap::new(); - map.insert("1".to_string(), ProposeNamespace { - ..Default::default() - }); - map - }); - assert!(namespaces - .supported(&ProposeNamespaces( - BTreeMap::::new() - )) - .is_ok()) - } - - #[test] - fn namespace_unsupported_chains_failure() { - let theirs = test_namespace(); - let mut ours = test_namespace(); - - ours.chains.remove("1"); - assert_eq!( - ours.supported(&theirs), - Err(ProposeNamespaceError::UnsupportedChains("1".to_string())), - ); - - ours.chains.remove("2"); - assert_eq!( - ours.supported(&theirs), - Err(ProposeNamespaceError::UnsupportedChains("1,2".to_string())), - ); - } - - #[test] - fn namespace_unsupported_methods_failure() { - let theirs = test_namespace(); - let mut ours = test_namespace(); - - ours.methods.remove("1"); - assert_eq!( - ours.supported(&theirs), - Err(ProposeNamespaceError::UnsupportedMethods("1".to_string())), - ); - - ours.methods.remove("2"); - assert_eq!( - ours.supported(&theirs), - Err(ProposeNamespaceError::UnsupportedMethods("1,2".to_string())), - ); - } - - #[test] - fn namespace_unsupported_events_failure() { - let theirs = test_namespace(); - let mut ours = test_namespace(); - - ours.events.remove("1"); - assert_eq!( - ours.supported(&theirs), - Err(ProposeNamespaceError::UnsupportedEvents("1".to_string())), - ); - - ours.events.remove("2"); - assert_eq!( - ours.supported(&theirs), - Err(ProposeNamespaceError::UnsupportedEvents("1,2".to_string())), - ); - } - - // ======================================================================================================== - // CAIP-2 TESTS: https://chainagnostic.org/CAIPs/caip-2 - // ======================================================================================================== - #[test] - fn caip2_test_cases() -> Result<(), ProposeNamespaceError> { - let chains = [ - // Ethereum mainnet - "eip155:1", - // Bitcoin mainnet (see https://github.com/bitcoin/bips/blob/master/bip-0122.mediawiki#definition-of-chain-id) - "bip122:000000000019d6689c085ae165831e93", - // Litecoin - "bip122:12a765e31ffd4059bada1e25190f6e98", - // Feathercoin (Litecoin fork) - "bip122:fdbe99b90c90bae7505796461471d89a", - // Cosmos Hub (Tendermint + Cosmos SDK) - "cosmos:cosmoshub-2", - "cosmos:cosmoshub-3", - // Binance chain (Tendermint + Cosmos SDK; see https://dataseed5.defibit.io/genesis) - "cosmos:Binance-Chain-Tigris", - // IOV Mainnet (Tendermint + weave) - "cosmos:iov-mainnet", - // StarkNet Testnet - "starknet:SN_GOERLI", - // Lisk Mainnet (LIP-0009; see https://github.com/LiskHQ/lips/blob/master/proposals/lip-0009.md) - "lip9:9ee11e9df416b18b", - // Dummy max length (8+1+32 = 41 chars/bytes) - "chainstd:8c3444cf8970a9e41a706fab93e7a6c4", - ]; - - let caip2_regex = get_caip2_regex(); - for chain in chains { - caip2_regex - .captures(chain) - .ok_or_else(|| ProposeNamespaceError::UnsupportedChainsCaip2(chain.to_string()))?; - } - - Ok(()) - } - - /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# - /// 12-proposal-namespaces-must-not-have-chains-empty - #[test] - fn caip2_12_chains_empty_failure() { - let namespaces = ProposeNamespaces({ - let mut map: BTreeMap = BTreeMap::new(); - map.insert("eip155".to_string(), ProposeNamespace { - ..Default::default() - }); - map - }); - - assert_eq!( - namespaces.caip2_validate(), - Err(ProposeNamespaceError::UnsupportedChainsEmpty), - ); - } - - /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# - /// 13-chains-might-be-omitted-if-the-caip-2-is-defined-in-the-index - #[test] - fn caip2_13_chains_omitted_success() -> Result<(), ProposeNamespaceError> { - let namespaces = ProposeNamespaces({ - let mut map: BTreeMap = BTreeMap::new(); - map.insert("eip155:1".to_string(), ProposeNamespace { - ..Default::default() - }); - map - }); - - namespaces.caip2_validate()?; - - Ok(()) - } - - /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# - /// 14-chains-must-be-caip-2-compliant - #[test] - fn caip2_14_must_be_compliant_failure() -> Result<(), ProposeNamespaceError> { - let namespaces = ProposeNamespaces({ - let mut map: BTreeMap = BTreeMap::new(); - map.insert("eip155".to_string(), ProposeNamespace { - chains: BTreeSet::from_iter(vec!["1".to_string()]), - ..Default::default() - }); - map - }); - - assert_eq!( - namespaces.caip2_validate(), - Err(ProposeNamespaceError::UnsupportedChainsCaip2( - "1".to_string() - )), - ); - - Ok(()) - } - - /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# - /// 16-all-chains-in-the-namespace-must-contain-the-namespace-prefix - #[test] - fn caip2_16_chain_prefix_success() -> Result<(), ProposeNamespaceError> { - let namespaces = ProposeNamespaces({ - let mut map: BTreeMap = BTreeMap::new(); - map.insert("eip155".to_string(), ProposeNamespace { - chains: BTreeSet::from_iter(vec!["eip155:1".to_string()]), - ..Default::default() - }); - map.insert("bip122".to_string(), ProposeNamespace { - chains: BTreeSet::from_iter(vec![ - "bip122:000000000019d6689c085ae165831e93".to_string(), - "bip122:12a765e31ffd4059bada1e25190f6e98".to_string(), - ]), - ..Default::default() - }); - map.insert("cosmos".to_string(), ProposeNamespace { - chains: BTreeSet::from_iter(vec![ - "cosmos:cosmoshub-2".to_string(), - "cosmos:cosmoshub-3".to_string(), - "cosmos:Binance-Chain-Tigris".to_string(), - "cosmos:iov-mainnet".to_string(), - ]), - ..Default::default() - }); - map.insert("starknet".to_string(), ProposeNamespace { - chains: BTreeSet::from_iter(vec!["starknet:SN_GOERLI".to_string()]), - ..Default::default() - }); - map.insert("chainstd".to_string(), ProposeNamespace { - chains: BTreeSet::from_iter(vec![ - "chainstd:8c3444cf8970a9e41a706fab93e7a6c4".to_string() - ]), - ..Default::default() - }); - map - }); - - namespaces.caip2_validate()?; - - Ok(()) - } - - /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# - /// 16-all-chains-in-the-namespace-must-contain-the-namespace-prefix - #[test] - fn caip2_16_chain_prefix_failure() -> Result<(), ProposeNamespaceError> { - let namespaces = ProposeNamespaces({ - let mut map: BTreeMap = BTreeMap::new(); - map.insert("eip155".to_string(), ProposeNamespace { - chains: BTreeSet::from_iter(vec!["cosmos:1".to_string()]), - ..Default::default() - }); - map - }); - - assert_eq!( - namespaces.caip2_validate(), - Err(ProposeNamespaceError::UnsupportedChainsNamespace( - "eip155".to_string(), - "cosmos".to_string() - )), - ); - - Ok(()) - } - - /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# - /// 17-namespace-key-must-comply-with-caip-2-specification - #[test] - fn caip2_17_namespace_key_failure() -> Result<(), ProposeNamespaceError> { - let namespaces = ProposeNamespaces({ - let mut map: BTreeMap = BTreeMap::new(); - map.insert("".to_string(), ProposeNamespace { - chains: BTreeSet::from_iter(vec![":1".to_string()]), - ..Default::default() - }); - map - }); - - assert_eq!( - namespaces.caip2_validate(), - Err(ProposeNamespaceError::UnsupportedNamespaceKey( - "".to_string() - )), - ); - - let namespaces = ProposeNamespaces({ - let mut map: BTreeMap = BTreeMap::new(); - map.insert("**".to_string(), ProposeNamespace { - chains: BTreeSet::from_iter(vec!["**:1".to_string()]), - ..Default::default() - }); - map - }); - - assert_eq!( - namespaces.caip2_validate(), - Err(ProposeNamespaceError::UnsupportedNamespaceKey( - "**".to_string() - )), - ); - - Ok(()) - } - - /// Trims json of the whitespaces and newlines. - /// - /// Allows to use "pretty json" in unittest, and still get consistent - /// results post serialization/deserialization. - pub fn param_json_trim(json: &str) -> String { - json.chars() - .filter(|c| !c.is_whitespace() && *c != '\n') - .collect::() - } - - /// Tests input json serialization/deserialization into the specified type. - pub fn param_serde_test(json: &str) -> Result<()> - where - T: Serialize + DeserializeOwned, - { - let expected = param_json_trim(json); - let deserialized: T = serde_json::from_str(&expected)?; - let actual = serde_json::to_string(&deserialized)?; - - assert_eq!(expected, actual); - - Ok(()) - } -} diff --git a/sign_api/src/rpc/params/session/propose.rs b/sign_api/src/rpc/params/session/propose.rs deleted file mode 100644 index 8ec7ed7..0000000 --- a/sign_api/src/rpc/params/session/propose.rs +++ /dev/null @@ -1,90 +0,0 @@ -use { - super::{IrnMetadata, ProposeNamespaces}, - crate::rpc::{Metadata, Relay}, - serde::{Deserialize, Serialize}, -}; - -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: ProposeNamespaces, -} - -#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SessionProposeResponse { - pub relay: Relay, - pub responder_public_key: String, -} - -#[cfg(test)] -mod tests { - use super::{super::tests::param_serde_test, *}; - - #[test] - fn test_serde_session_propose_request() { - // https://specs.walletconnect.com/2.0/specs/clients/sign/ - // session-events#session_propose - let json = r#" - { - "relays": [ - { - "protocol": "irn" - } - ], - "proposer": { - "publicKey": "a3ad5e26070ddb2809200c6f56e739333512015bceeadbb8ea1731c4c7ddb207", - "metadata": { - "description": "React App for WalletConnect", - "url": "http://localhost:3000", - "icons": [ - "https://avatars.githubusercontent.com/u/37784886" - ], - "name": "React App" - } - }, - "requiredNamespaces": { - "eip155": { - "chains": [ - "eip155:5" - ], - "methods": [ - "eth_sendTransaction", - "eth_sign", - "eth_signTransaction", - "eth_signTypedData", - "personal_sign" - ], - "events": [ - "accountsChanged", - "chainChanged" - ] - } - } - } - "#; - - param_serde_test::(json); - } -} From 742f9a8954b38c571fe81870856845acdb2f4177 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 28 Aug 2024 04:58:35 +0100 Subject: [PATCH 06/18] save dev state - pre-working session test --- relay_rpc/src/rpc/params.rs | 20 + relay_rpc/src/rpc/params/params.rs | 20 + relay_rpc/src/rpc/params/session.rs | 757 ++++++++++++++++++++ relay_rpc/src/rpc/params/session/propose.rs | 90 +++ relay_rpc/src/rpc/params/session/settle.rs | 91 +++ sign_api/examples/session.rs | 491 +++++++++++++ 6 files changed, 1469 insertions(+) create mode 100644 relay_rpc/src/rpc/params.rs create mode 100644 relay_rpc/src/rpc/params/params.rs create mode 100644 relay_rpc/src/rpc/params/session.rs create mode 100644 relay_rpc/src/rpc/params/session/propose.rs create mode 100644 relay_rpc/src/rpc/params/session/settle.rs create mode 100644 sign_api/examples/session.rs diff --git a/relay_rpc/src/rpc/params.rs b/relay_rpc/src/rpc/params.rs new file mode 100644 index 0000000..7fc91d0 --- /dev/null +++ b/relay_rpc/src/rpc/params.rs @@ -0,0 +1,20 @@ +pub mod session; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, 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, Hash, Deserialize, Clone, Default)] +pub struct Relay { + pub protocol: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub data: Option, +} diff --git a/relay_rpc/src/rpc/params/params.rs b/relay_rpc/src/rpc/params/params.rs new file mode 100644 index 0000000..21c7800 --- /dev/null +++ b/relay_rpc/src/rpc/params/params.rs @@ -0,0 +1,20 @@ +pub mod session; + +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, +} diff --git a/relay_rpc/src/rpc/params/session.rs b/relay_rpc/src/rpc/params/session.rs new file mode 100644 index 0000000..ab87653 --- /dev/null +++ b/relay_rpc/src/rpc/params/session.rs @@ -0,0 +1,757 @@ +pub mod propose; +pub mod settle; + +use { + paste::paste, + propose::{SessionProposeRequest, SessionProposeResponse}, + regex::Regex, + serde::{Deserialize, Serialize}, + serde_json::Value, + settle::SessionSettleRequest, + std::{ + collections::{BTreeMap, BTreeSet}, + ops::Deref, + sync::OnceLock, + }, +}; + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces +/// +/// https://chainagnostic.org/CAIPs/caip-2 +/// +/// chain_id: namespace + ":" + reference +/// namespace: [-a-z0-9]{3,8} +/// reference: [-_a-zA-Z0-9]{1,32} +static CAIP2_REGEX: OnceLock = OnceLock::new(); +fn get_caip2_regex() -> &'static Regex { + CAIP2_REGEX.get_or_init(|| { + Regex::new(r"^(?P[-[:alnum:]]{3,8})((?::)(?P[-_[:alnum:]]{1,32}))?$") + .expect("invalid regex: unexpected error") + }) +} + +/// Errors covering namespace validation errors. +/// +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces +/// and some additional variants. +#[derive(Debug, thiserror::Error, Eq, PartialEq)] +pub enum ProposeNamespaceError { + #[error("Required chains are not supported: {0}")] + UnsupportedChains(String), + #[error("Chains must not be empty")] + UnsupportedChainsEmpty, + #[error("Chains must be CAIP-2 compliant: {0}")] + UnsupportedChainsCaip2(String), + #[error("Chains must be defined in matching namespace: expected={0}, actual={1}")] + UnsupportedChainsNamespace(String, String), + #[error("Required events are not supported: {0}")] + UnsupportedEvents(String), + #[error("Required methods are not supported: {0}")] + UnsupportedMethods(String), + #[error("Required namespace is not supported: {0}")] + UnsupportedNamespace(String), + #[error("Namespace formatting must match CAIP-2: {0}")] + UnsupportedNamespaceKey(String), +} + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# +/// proposal-namespace +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct ProposeNamespace { + pub chains: BTreeSet, + pub methods: BTreeSet, + pub events: BTreeSet, +} + +impl ProposeNamespace { + fn supported(&self, required: &Self) -> Result<(), ProposeNamespaceError> { + let join_err = |required: &BTreeSet, ours: &BTreeSet| -> String { + return required + .difference(ours) + .map(|s| s.as_str()) + .collect::>() + .join(","); + }; + + // validate chains + if !self.chains.is_superset(&required.chains) { + return Err(ProposeNamespaceError::UnsupportedChains(join_err( + &required.chains, + &self.chains, + ))); + } + + // validate methods + if !self.methods.is_superset(&required.methods) { + return Err(ProposeNamespaceError::UnsupportedMethods(join_err( + &required.methods, + &self.methods, + ))); + } + + // validate events + if !self.events.is_superset(&required.events) { + return Err(ProposeNamespaceError::UnsupportedEvents(join_err( + &required.events, + &self.events, + ))); + } + + Ok(()) + } + + pub fn chains_caip2_validate( + &self, + namespace: &str, + reference: Option<&str>, + ) -> Result<(), ProposeNamespaceError> { + // https://specs.walletconnect.com/2.0/specs/clients/sign/ + // namespaces#13-chains-might-be-omitted-if-the-caip-2-is-defined-in-the-index + match (reference, self.chains.is_empty()) { + (None, true) => return Err(ProposeNamespaceError::UnsupportedChainsEmpty), + (Some(_), true) => return Ok(()), + _ => {} + } + + let caip_regex = get_caip2_regex(); + for chain in self.chains.iter() { + let captures = caip_regex + .captures(chain) + .ok_or_else(|| ProposeNamespaceError::UnsupportedChainsCaip2(chain.to_string()))?; + + let chain_namespace = captures + .name("namespace") + .expect("chain namespace name is missing: unexpected error") + .as_str(); + + if namespace != chain_namespace { + return Err(ProposeNamespaceError::UnsupportedChainsNamespace( + namespace.to_string(), + chain_namespace.to_string(), + )); + } + + let chain_reference = + captures + .name("reference") + .map(|m| m.as_str()) + .ok_or_else(|| { + ProposeNamespaceError::UnsupportedChainsCaip2(namespace.to_string()) + })?; + + if let Some(r) = reference { + if r != chain_reference { + return Err(ProposeNamespaceError::UnsupportedChainsCaip2( + namespace.to_string(), + )); + } + } + } + + Ok(()) + } +} + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces +#[derive(Debug, Serialize, Eq, PartialEq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct ProposeNamespaces(pub BTreeMap); + +impl Deref for ProposeNamespaces { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl ProposeNamespaces { + /// Ensures that application is compatible with the requester requirements. + /// + /// Implementation must support at least all the elements in `required`. + pub fn supported(&self, required: &ProposeNamespaces) -> Result<(), ProposeNamespaceError> { + if self.is_empty() { + return Err(ProposeNamespaceError::UnsupportedNamespace( + "None supported".to_string(), + )); + } + + if required.is_empty() { + return Ok(()); + } + + for (name, other) in required.iter() { + let ours = self + .get(name) + .ok_or_else(|| ProposeNamespaceError::UnsupportedNamespace(name.to_string()))?; + ours.supported(other)?; + } + + Ok(()) + } + + pub fn caip2_validate(&self) -> Result<(), ProposeNamespaceError> { + let caip_regex = get_caip2_regex(); + for (name, namespace) in self.deref() { + let captures = caip_regex + .captures(name) + .ok_or_else(|| ProposeNamespaceError::UnsupportedNamespaceKey(name.to_string()))?; + + let name = captures + .name("namespace") + .expect("namespace name missing: unexpected error") + .as_str(); + + let reference = captures.name("reference").map(|m| m.as_str()); + + namespace.chains_caip2_validate(name, reference)?; + } + + Ok(()) + } +} + +/// Errors covering Sign API payload parameter conversion issues. +#[derive(Debug, thiserror::Error)] +pub enum ParamsError { + /// Sign API serialization/deserialization issues. + #[error("Failure serializing/deserializing Sign API parameters: {0}")] + Serde(#[from] serde_json::Error), + /// Sign API invalid response tag. + #[error("Response tag={0} does not match any of the Sign API methods")] + ResponseTag(u32), +} + +/// TODO: some validation from `ProposeNamespaces` should be re-used. +/// TODO: caip-10 validation. +/// TODO: named errors like in `ProposeNamespaces`. +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettleNamespaces(pub BTreeMap); + +impl Deref for SettleNamespaces { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettleNamespace { + pub accounts: BTreeSet, + pub methods: BTreeSet, + pub events: BTreeSet, +} + +/// 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(_) => propose::[], + [<$param_type>]::SessionSettle(_) => propose::[], + + } + } + } + } + } +} + +// 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 == propose::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionPropose(serde_json::from_value(value)?)) + } else if tag == settle::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionSettle(serde_json::from_value(value)?)) + } else { + Err(ParamsError::ResponseTag(tag)) + } + } + } + } + }; +} + +/// 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 = ParamsError; + + 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 = ParamsError; + + fn try_from(value: ResponseParamsError) -> Result { + Ok(Self::Err(serde_json::to_value(value)?)) + } +} + +#[cfg(test)] +mod tests { + use {super::*, anyhow::Result, serde::de::DeserializeOwned, serde_json}; + + // ======================================================================================================== + // https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + // rejecting-a-session-response + // - validates namespaces match at least all requiredNamespaces + // ======================================================================================================== + + fn test_namespace() -> ProposeNamespace { + let test_vec = vec![ + "0".to_string(), + "1".to_string(), + "2".to_string(), + "3".to_string(), + "4".to_string(), + ]; + ProposeNamespace { + chains: BTreeSet::from_iter(test_vec.clone()), + methods: BTreeSet::from_iter(test_vec.clone()), + events: BTreeSet::from_iter(test_vec.clone()), + } + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 19-proposal-namespaces-may-be-empty + #[test] + fn namespaces_required_empty_success() { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("1".to_string(), ProposeNamespace { + ..Default::default() + }); + map + }); + assert!(namespaces + .supported(&ProposeNamespaces( + BTreeMap::::new() + )) + .is_ok()) + } + + #[test] + fn namespace_unsupported_chains_failure() { + let theirs = test_namespace(); + let mut ours = test_namespace(); + + ours.chains.remove("1"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedChains("1".to_string())), + ); + + ours.chains.remove("2"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedChains("1,2".to_string())), + ); + } + + #[test] + fn namespace_unsupported_methods_failure() { + let theirs = test_namespace(); + let mut ours = test_namespace(); + + ours.methods.remove("1"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedMethods("1".to_string())), + ); + + ours.methods.remove("2"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedMethods("1,2".to_string())), + ); + } + + #[test] + fn namespace_unsupported_events_failure() { + let theirs = test_namespace(); + let mut ours = test_namespace(); + + ours.events.remove("1"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedEvents("1".to_string())), + ); + + ours.events.remove("2"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedEvents("1,2".to_string())), + ); + } + + // ======================================================================================================== + // CAIP-2 TESTS: https://chainagnostic.org/CAIPs/caip-2 + // ======================================================================================================== + #[test] + fn caip2_test_cases() -> Result<(), ProposeNamespaceError> { + let chains = [ + // Ethereum mainnet + "eip155:1", + // Bitcoin mainnet (see https://github.com/bitcoin/bips/blob/master/bip-0122.mediawiki#definition-of-chain-id) + "bip122:000000000019d6689c085ae165831e93", + // Litecoin + "bip122:12a765e31ffd4059bada1e25190f6e98", + // Feathercoin (Litecoin fork) + "bip122:fdbe99b90c90bae7505796461471d89a", + // Cosmos Hub (Tendermint + Cosmos SDK) + "cosmos:cosmoshub-2", + "cosmos:cosmoshub-3", + // Binance chain (Tendermint + Cosmos SDK; see https://dataseed5.defibit.io/genesis) + "cosmos:Binance-Chain-Tigris", + // IOV Mainnet (Tendermint + weave) + "cosmos:iov-mainnet", + // StarkNet Testnet + "starknet:SN_GOERLI", + // Lisk Mainnet (LIP-0009; see https://github.com/LiskHQ/lips/blob/master/proposals/lip-0009.md) + "lip9:9ee11e9df416b18b", + // Dummy max length (8+1+32 = 41 chars/bytes) + "chainstd:8c3444cf8970a9e41a706fab93e7a6c4", + ]; + + let caip2_regex = get_caip2_regex(); + for chain in chains { + caip2_regex + .captures(chain) + .ok_or_else(|| ProposeNamespaceError::UnsupportedChainsCaip2(chain.to_string()))?; + } + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 12-proposal-namespaces-must-not-have-chains-empty + #[test] + fn caip2_12_chains_empty_failure() { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155".to_string(), ProposeNamespace { + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedChainsEmpty), + ); + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 13-chains-might-be-omitted-if-the-caip-2-is-defined-in-the-index + #[test] + fn caip2_13_chains_omitted_success() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155:1".to_string(), ProposeNamespace { + ..Default::default() + }); + map + }); + + namespaces.caip2_validate()?; + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 14-chains-must-be-caip-2-compliant + #[test] + fn caip2_14_must_be_compliant_failure() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["1".to_string()]), + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedChainsCaip2( + "1".to_string() + )), + ); + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 16-all-chains-in-the-namespace-must-contain-the-namespace-prefix + #[test] + fn caip2_16_chain_prefix_success() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["eip155:1".to_string()]), + ..Default::default() + }); + map.insert("bip122".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec![ + "bip122:000000000019d6689c085ae165831e93".to_string(), + "bip122:12a765e31ffd4059bada1e25190f6e98".to_string(), + ]), + ..Default::default() + }); + map.insert("cosmos".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec![ + "cosmos:cosmoshub-2".to_string(), + "cosmos:cosmoshub-3".to_string(), + "cosmos:Binance-Chain-Tigris".to_string(), + "cosmos:iov-mainnet".to_string(), + ]), + ..Default::default() + }); + map.insert("starknet".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["starknet:SN_GOERLI".to_string()]), + ..Default::default() + }); + map.insert("chainstd".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec![ + "chainstd:8c3444cf8970a9e41a706fab93e7a6c4".to_string() + ]), + ..Default::default() + }); + map + }); + + namespaces.caip2_validate()?; + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 16-all-chains-in-the-namespace-must-contain-the-namespace-prefix + #[test] + fn caip2_16_chain_prefix_failure() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["cosmos:1".to_string()]), + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedChainsNamespace( + "eip155".to_string(), + "cosmos".to_string() + )), + ); + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 17-namespace-key-must-comply-with-caip-2-specification + #[test] + fn caip2_17_namespace_key_failure() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec![":1".to_string()]), + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedNamespaceKey( + "".to_string() + )), + ); + + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("**".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["**:1".to_string()]), + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedNamespaceKey( + "**".to_string() + )), + ); + + Ok(()) + } + + /// Trims json of the whitespaces and newlines. + /// + /// Allows to use "pretty json" in unittest, and still get consistent + /// results post serialization/deserialization. + pub fn param_json_trim(json: &str) -> String { + json.chars() + .filter(|c| !c.is_whitespace() && *c != '\n') + .collect::() + } + + /// Tests input json serialization/deserialization into the specified type. + pub fn param_serde_test(json: &str) -> Result<()> + where + T: Serialize + DeserializeOwned, + { + let expected = param_json_trim(json); + let deserialized: T = serde_json::from_str(&expected)?; + let actual = serde_json::to_string(&deserialized)?; + + assert_eq!(expected, actual); + + Ok(()) + } +} diff --git a/relay_rpc/src/rpc/params/session/propose.rs b/relay_rpc/src/rpc/params/session/propose.rs new file mode 100644 index 0000000..5820f18 --- /dev/null +++ b/relay_rpc/src/rpc/params/session/propose.rs @@ -0,0 +1,90 @@ +use { + super::{IrnMetadata, ProposeNamespaces}, + crate::rpc::params::{Metadata, Relay}, + serde::{Deserialize, Serialize}, +}; + +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, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Proposer { + pub public_key: String, + pub metadata: Metadata, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionProposeRequest { + pub relays: Vec, + pub proposer: Proposer, + pub required_namespaces: ProposeNamespaces, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionProposeResponse { + pub relay: Relay, + pub responder_public_key: String, +} + +#[cfg(test)] +mod tests { + use super::{super::tests::param_serde_test, *}; + + #[test] + fn test_serde_session_propose_request() { + // https://specs.walletconnect.com/2.0/specs/clients/sign/ + // session-events#session_propose + let json = r#" + { + "relays": [ + { + "protocol": "irn" + } + ], + "proposer": { + "publicKey": "a3ad5e26070ddb2809200c6f56e739333512015bceeadbb8ea1731c4c7ddb207", + "metadata": { + "description": "React App for WalletConnect", + "url": "http://localhost:3000", + "icons": [ + "https://avatars.githubusercontent.com/u/37784886" + ], + "name": "React App" + } + }, + "requiredNamespaces": { + "eip155": { + "chains": [ + "eip155:5" + ], + "methods": [ + "eth_sendTransaction", + "eth_sign", + "eth_signTransaction", + "eth_signTypedData", + "personal_sign" + ], + "events": [ + "accountsChanged", + "chainChanged" + ] + } + } + } + "#; + + param_serde_test::(json); + } +} diff --git a/relay_rpc/src/rpc/params/session/settle.rs b/relay_rpc/src/rpc/params/session/settle.rs new file mode 100644 index 0000000..1feaa7b --- /dev/null +++ b/relay_rpc/src/rpc/params/session/settle.rs @@ -0,0 +1,91 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionsettle + +use { + super::{IrnMetadata, SettleNamespaces}, + crate::rpc::params::{Metadata, Relay}, + serde::{Deserialize, Serialize}, +}; + +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, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Controller { + pub public_key: String, + pub metadata: Metadata, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SessionSettleRequest { + pub relay: Relay, + pub controller: Controller, + pub namespaces: SettleNamespaces, + /// Unix timestamp. + /// + /// Expiry should be between .now() + TTL. + pub expiry: u64, +} + +#[cfg(test)] +mod tests { + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; + + #[test] + fn test_serde_session_settle_request() -> Result<()> { + // Coppied from `session_propose` and adjusted slightly. + let json = r#" + { + "relay": { + "protocol": "irn" + }, + "controller": { + "publicKey": "a3ad5e26070ddb2809200c6f56e739333512015bceeadbb8ea1731c4c7ddb207", + "metadata": { + "description": "React App for WalletConnect", + "url": "http://localhost:3000", + "icons": [ + "https://avatars.githubusercontent.com/u/37784886" + ], + "name": "React App" + } + }, + "namespaces": { + "eip155": { + "accounts": [ + "eip155:5:0xBA5BA3955463ADcc7aa3E33bbdfb8A68e0933dD8" + ], + "methods": [ + "eth_sendTransaction", + "eth_sign", + "eth_signTransaction", + "eth_signTypedData", + "personal_sign" + ], + "events": [ + "accountsChanged", + "chainChanged" + ] + } + }, + "expiry": 1675734962 + } + "#; + + param_serde_test::(json) + } +} diff --git a/sign_api/examples/session.rs b/sign_api/examples/session.rs new file mode 100644 index 0000000..3edb62c --- /dev/null +++ b/sign_api/examples/session.rs @@ -0,0 +1,491 @@ +use { + anyhow::Result, + chrono::Utc, + clap::Parser, + relay_client::{ + error::{ClientError, Error}, + websocket::{Client, CloseFrame, ConnectionHandler, PublishedMessage}, + ConnectionOptions, + MessageIdGenerator, + }, + relay_rpc::{ + auth::{ed25519_dalek::SigningKey, AuthToken}, + domain::{MessageId, SubscriptionId, Topic}, + rpc::{ + params::{ + session::{ + propose::{SessionProposeRequest, SessionProposeResponse}, + settle::{Controller, SessionSettleRequest}, + IrnMetadata, + ProposeNamespace, + ProposeNamespaces, + RelayProtocolMetadata, + RequestParams, + ResponseParamsSuccess, + SettleNamespace, + SettleNamespaces, + }, + Metadata, + Relay, + }, + Params, + Payload, + Request, + Response, + SuccessfulResponse, + JSON_RPC_VERSION_STR, + }, + }, + sign_api::{ + crypto::{decode_and_decrypt_type0, encrypt_and_encode, EnvelopeType}, + session::SessionKey, + Pairing as PairingData, + }, + std::{ + collections::{BTreeMap, HashMap}, + str::FromStr, + sync::Arc, + time::Duration, + }, + tokio::{ + select, + sync::{ + mpsc::{channel, unbounded_channel, Sender, UnboundedSender}, + Mutex, + }, + }, +}; + +const SUPPORTED_PROTOCOL: &str = "irn"; +const SUPPORTED_METHODS: &[&str] = &[ + "eth_sendTransaction", + "eth_signTransaction", + "eth_sign", + "personal_sign", + "eth_signTypedData", +]; +const SUPPORTED_CHAINS: &[&str] = &["eip155:1", "eip155:5"]; +const SUPPORTED_EVENTS: &[&str] = &["chainChanged", "accountsChanged"]; +const SUPPORTED_ACCOUNTS: &[&str] = &["eip155:5:0xBA5BA3955463ADcc7aa3E33bbdfb8A68e0933dD8"]; + +// Establish Session. +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Arg { + /// Goerli https://react-app.walletconnect.com/ pairing URI. + pairing_uri: String, + + /// Specify WebSocket address. + #[arg(short, long, default_value = "wss://relay.walletconnect.com")] + address: String, + + /// Specify WalletConnect project ID. + #[arg(short, long, default_value = "86e916bcbacee7f98225dde86b697f5b")] + project_id: String, +} + +struct Handler { + name: &'static str, + sender: UnboundedSender, +} + +impl Handler { + fn new(name: &'static str, sender: UnboundedSender) -> Self { + Self { name, sender } + } +} + +impl ConnectionHandler for Handler { + fn connected(&mut self) { + println!("\n[{}] connection open", self.name); + } + + fn disconnected(&mut self, frame: Option>) { + println!("\n[{}] connection closed: frame={frame:?}", self.name); + } + + fn message_received(&mut self, message: PublishedMessage) { + println!( + "\n[{}] inbound message: message_id={} topic={} tag={} message={}", + self.name, message.message_id, message.topic, message.tag, message.message, + ); + + if let Err(e) = self.sender.send(message) { + println!("\n[{}] failed to send the to the receiver: {e}", self.name); + } + } + + fn inbound_error(&mut self, error: ClientError) { + println!("\n[{}] inbound error: {error}", self.name); + } + + fn outbound_error(&mut self, error: ClientError) { + println!("\n[{}] outbound error: {error}", self.name); + } +} + +fn create_conn_opts(address: &str, project_id: &str) -> ConnectionOptions { + let key = SigningKey::generate(&mut rand::thread_rng()); + + let auth = AuthToken::new("http://example.com") + .aud(address) + .ttl(Duration::from_secs(60 * 60)) + .as_jwt(&key) + .unwrap(); + + ConnectionOptions::new(project_id, auth).with_address(address) +} + +fn supported_propose_namespaces() -> ProposeNamespaces { + ProposeNamespaces({ + let mut map = BTreeMap::::new(); + map.insert("eip155".to_string(), ProposeNamespace { + chains: SUPPORTED_CHAINS.iter().map(|c| c.to_string()).collect(), + methods: SUPPORTED_METHODS.iter().map(|m| m.to_string()).collect(), + events: SUPPORTED_EVENTS.iter().map(|e| e.to_string()).collect(), + ..Default::default() + }); + map + }) +} + +fn supported_settle_namespaces() -> SettleNamespaces { + SettleNamespaces({ + let mut map = BTreeMap::::new(); + map.insert("eip155".to_string(), SettleNamespace { + accounts: SUPPORTED_ACCOUNTS.iter().map(|a| a.to_string()).collect(), + methods: SUPPORTED_METHODS.iter().map(|m| m.to_string()).collect(), + events: SUPPORTED_EVENTS.iter().map(|e| e.to_string()).collect(), + ..Default::default() + }); + map + }) +} + +fn create_settle_request(responder_public_key: String) -> RequestParams { + RequestParams::SessionSettle(SessionSettleRequest { + relay: Relay { + protocol: SUPPORTED_PROTOCOL.to_string(), + data: None, + }, + controller: Controller { + public_key: responder_public_key.to_string(), + metadata: Metadata { + name: format!("Rust session example: {}", Utc::now()), + icons: vec!["https://www.rust-lang.org/static/images/rust-logo-blk.svg".to_string()], + ..Default::default() + }, + }, + namespaces: supported_settle_namespaces(), + expiry: Utc::now().timestamp() as u64 + 300, // 5 min TTL + }) +} + +fn create_proposal_response(responder_public_key: String) -> ResponseParamsSuccess { + ResponseParamsSuccess::SessionPropose(SessionProposeResponse { + relay: Relay { + protocol: SUPPORTED_PROTOCOL.to_string(), + data: None, + }, + responder_public_key, + }) +} + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/session-proposal +async fn process_proposal_request( + context: Arc>, + proposal: SessionProposeRequest, +) -> Result { + supported_propose_namespaces().supported(&proposal.required_namespaces)?; + + let sender_public_key = hex::decode(&proposal.proposer.public_key)? + .as_slice() + .try_into()?; + + let session_key = SessionKey::from_osrng(&sender_public_key)?; + let responder_public_key = hex::encode(session_key.diffie_public_key()); + let session_topic: Topic = session_key.generate_topic().try_into()?; + + { + let mut context = context.lock().await; + let subscription_id = context.client.subscribe(session_topic.clone()).await?; + _ = context.sessions.insert(session_topic.clone(), Session { + session_key, + subscription_id, + }); + + let settle_params = create_settle_request(responder_public_key.clone()); + context + .publish_request(session_topic, settle_params) + .await?; + } + Ok(create_proposal_response(responder_public_key)) +} + +// fn process_session_delete_request(delete_params: SessionDeleteRequest) -> +// ResponseParamsSuccess { println!( +// "\nSession is being terminated reason={}, code={}", +// delete_params.message, delete_params.code, +// ); + +// ResponseParamsSuccess::SessionDelete(true) +// } + +async fn process_inbound_request( + context: Arc>, + request: Request, + topic: Topic, +) -> Result<()> { + let mut session_delete_cleanup_required: Option = None; + let response = match request.params { + Params::SessionPropose(proposal) => { + process_proposal_request(context.clone(), proposal).await? + } + // RequestParams::SessionDelete(params) => { + // session_delete_cleanup_required = Some(topic.clone()); + // process_session_delete_request(params) + // } + // RequestParams::SessionPing(_) => ResponseParamsSuccess::SessionPing(true), + _ => todo!(), + }; + + let mut context = context.lock().await; + context + .publish_success_response(topic, request.id, response) + .await?; + + // Corner case after the session was closed by the dapp. + // if let Some(topic) = session_delete_cleanup_required { + // context.session_delete_cleanup(topic).await? + // } + + Ok(()) +} + +fn process_inbound_response(response: Response) -> Result<()> { + match response { + Response::Success(value) => { + let params = serde_json::from_value::(value.result)?; + match params { + ResponseParamsSuccess::SessionSettle(b) => { + if !b { + anyhow::bail!("Unsuccessful response={params:?}"); + } + + Ok(()) + } + _ => todo!(), + } + } + Response::Error(value) => { + // let params = serde_json::from_value::(value.error)?; + anyhow::bail!("DApp send and error response: {value:?}"); + } + } +} + +async fn process_inbound_message( + context: Arc>, + message: PublishedMessage, +) -> Result<()> { + let plain = { + let mut context = context.lock().await; + context.peek_sym_key(&message.topic, |key| { + decode_and_decrypt_type0(message.message.as_bytes(), key) + .map_err(|e| anyhow::anyhow!(e)) + })? + }; + + println!("\nPlain payload={plain}"); + let payload: Payload = serde_json::from_str(&plain)?; + + match payload { + Payload::Request(request) => process_inbound_request(context, request, message.topic).await, + Payload::Response(response) => process_inbound_response(response), + } +} + +async fn inbound_handler(context: Arc>, message: PublishedMessage) { + if !Payload::irn_tag_in_range(message.tag) { + println!( + "\ntag={} skip handling, doesn't belong to Sign API", + message.tag + ); + return; + } + + match process_inbound_message(context, message).await { + Ok(_) => println!("\nMessage was successfully handled"), + Err(e) => println!("\nFailed to handle the message={e}"), + } +} + +/// https://specs.walletconnect.com/2.0/specs/clients/core/pairing +struct Pairing { + /// Termination signal for when all sessions have been closed. + terminator: Sender<()>, + /// Pairing topic. + topic: Topic, + /// Pairing subscription id. + subscription_id: SubscriptionId, + /// Pairing symmetric key. + /// + /// https://specs.walletconnect.com/2.0/specs/clients/core/crypto/ + /// crypto-keys#key-algorithms + sym_key: [u8; 32], +} + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/session-proposal +/// +/// New session as the result of successful session proposal. +struct Session { + /// Pairing subscription id. + subscription_id: SubscriptionId, + /// Session symmetric key. + /// + /// https://specs.walletconnect.com/2.0/specs/clients/core/crypto/ + /// crypto-keys#key-algorithms + session_key: SessionKey, +} + +/// WCv2 client context. +struct Context { + /// Relay WS client to send and receive messages. + /// + /// TODO: assumed re-entrant/thread-safe? + client: Client, + pairing: Pairing, + /// All session belonging to `pairing`. + /// + /// Uniquely identified by the topic. + sessions: HashMap, +} + +impl Context { + fn new(client: Client, pairing: Pairing) -> Arc> { + Arc::new(Mutex::new(Self { + client, + pairing, + sessions: HashMap::new(), + })) + } + + /// Provides read access to the symmetric encryption/decryption key. + /// + /// Read lock is held for the duration of the call. + fn peek_sym_key(&self, topic: &Topic, f: F) -> Result + where + F: FnOnce(&[u8; 32]) -> Result, + { + if &self.pairing.topic == topic { + f(&self.pairing.sym_key) + } else { + let session = self + .sessions + .get(topic) + .ok_or_else(|| anyhow::anyhow!("Missing sym key for topic={} ", topic))?; + + f(&session.session_key.symmetric_key()) + } + } + + async fn publish_request(&self, topic: Topic, params: RequestParams) -> Result<()> { + let irn_helpers = params.irn_metadata(); + let message_id = MessageIdGenerator::new().next(); + let request = Request::new(message_id, params.into()); + let payload = serde_json::to_string(&Payload::from(request))?; + println!("\nSending request topic={topic} payload={payload}"); + self.publish_payload(topic, irn_helpers, &payload).await + } + + async fn publish_success_response( + &self, + topic: Topic, + id: MessageId, + params: ResponseParamsSuccess, + ) -> Result<()> { + let irn_metadata = params.irn_metadata(); + let response = Response::Success(SuccessfulResponse { + id, + jsonrpc: JSON_RPC_VERSION_STR.into(), + result: serde_json::to_value(params).unwrap(), + }); + let payload = serde_json::to_string(&Payload::from(response))?; + println!("\nSending response topic={topic} payload={payload}"); + self.publish_payload(topic, irn_metadata, &payload).await + } + + async fn publish_payload( + &self, + topic: Topic, + irn_metadata: IrnMetadata, + payload: &str, + ) -> Result<()> { + let encrypted = self.peek_sym_key(&topic, |key| { + encrypt_and_encode(EnvelopeType::Type0, &payload, key).map_err(|e| anyhow::anyhow!(e)) + })?; + + println!("\nOutbound encrypted payload={encrypted}"); + + self.client + .publish( + topic, + Arc::from(encrypted), + None, + irn_metadata.tag, + Duration::from_secs(irn_metadata.ttl), + irn_metadata.prompt, + ) + .await?; + + Ok(()) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Arg::parse(); + let pairing = PairingData::from_str(&args.pairing_uri)?; + let topic: Topic = pairing.topic.try_into()?; + let (inbound_sender, mut inbound_receiver) = unbounded_channel(); + let (terminate_sender, mut terminate_receiver) = channel::<()>(1); + + let client = Client::new(Handler::new("example_wallet", inbound_sender)); + client + .connect(&create_conn_opts(&args.address, &args.project_id)) + .await?; + + let subscription_id = client.subscribe(topic.clone()).await?; + println!("\n[client1] subscribed: topic={topic} subscription_id={subscription_id}"); + + let context = Context::new(client, Pairing { + terminator: terminate_sender, + topic, + sym_key: pairing.params.sym_key.as_slice().try_into()?, + subscription_id, + }); + + // Processes inbound messages until termination signal is received. + loop { + let context = context.clone(); + select! { + message = inbound_receiver.recv() => { + match message { + Some(m) => { + tokio::spawn(async move { inbound_handler(context, m).await }); + }, + None => { + break; + } + } + + } + _ = terminate_receiver.recv() => { + terminate_receiver.close(); + inbound_receiver.close(); + } + }; + } + + Ok(()) +} From d3eebb5c324e0b606ea5e7084f6628517892d11c Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 28 Aug 2024 11:50:17 +0100 Subject: [PATCH 07/18] include other session rpc methods/params --- relay_rpc/src/rpc.rs | 28 +++++++--- relay_rpc/src/rpc/params/session.rs | 81 ++++++++++++++++------------ sign_api/examples/session.rs | 84 ++++++++++++++++++++--------- sign_api/src/pairing_uri.rs | 9 +--- 4 files changed, 129 insertions(+), 73 deletions(-) diff --git a/relay_rpc/src/rpc.rs b/relay_rpc/src/rpc.rs index fda6902..607ca53 100644 --- a/relay_rpc/src/rpc.rs +++ b/relay_rpc/src/rpc.rs @@ -4,9 +4,7 @@ use { crate::domain::{DidKey, MessageId, SubscriptionId, Topic}, params::session::{ - propose::SessionProposeRequest, - settle::SessionSettleRequest, - RequestParams, + delete::SessionDeleteRequest, event::SessionEventRequest, extend::SessionExtendRequest, ping::SessionPingRequest, propose::SessionProposeRequest, request::SessionRequestRequest, settle::SessionSettleRequest, update::SessionUpdateRequest, RequestParams }, serde::{de::DeserializeOwned, Deserialize, Serialize}, std::{fmt::Debug, sync::Arc}, @@ -830,18 +828,35 @@ pub enum Params { /// topic the data is published for. #[serde(rename = "irn_subscription", alias = "iridium_subscription")] Subscription(Subscription), - #[serde(rename = "wc_sessionPropose")] SessionPropose(SessionProposeRequest), #[serde(rename = "wc_sessionSettle")] SessionSettle(SessionSettleRequest), + #[serde(rename = "wc_sessionRequest")] + SessionRequest(SessionRequestRequest), + #[serde(rename = "wc_sessionEvent")] + SessionEvent(SessionEventRequest), + #[serde(rename = "wc_sessionUpdate")] + SessionUpdate(SessionUpdateRequest), + #[serde(rename = "wc_sessionDelete")] + SessionDelete(SessionDeleteRequest), + #[serde(rename = "wc_sessionExtend")] + SessionExtend(SessionExtendRequest), + #[serde(rename = "wc_sessionPing")] + SessionPing(()), } impl From for Params { fn from(value: RequestParams) -> Self { match value { - RequestParams::SessionPropose(param) => Params::SessionPropose(param), - RequestParams::SessionSettle(param) => Params::SessionSettle(param), + RequestParams::SessionEvent(params) => Params::SessionEvent(params), + RequestParams::SessionSettle(params) => Params::SessionSettle(params), + RequestParams::SessionExtend(params) => Params::SessionExtend(params), + RequestParams::SessionUpdate(params) => Params::SessionUpdate(params), + RequestParams::SessionPropose(params) => Params::SessionPropose(params), + RequestParams::SessionRequest(params) => Params::SessionRequest(params), + RequestParams::SessionDelete(params) => Params::SessionDelete(params), + RequestParams::SessionPing(()) => Params::SessionPing(()), } } } @@ -894,6 +909,7 @@ impl Request { Params::WatchRegister(params) => params.validate(), Params::WatchUnregister(params) => params.validate(), Params::Subscription(params) => params.validate(), + // Params::SessionPropose(params) => params.vl _ => Ok(()), } } diff --git a/relay_rpc/src/rpc/params/session.rs b/relay_rpc/src/rpc/params/session.rs index ab87653..b7b0570 100644 --- a/relay_rpc/src/rpc/params/session.rs +++ b/relay_rpc/src/rpc/params/session.rs @@ -1,18 +1,18 @@ pub mod propose; pub mod settle; +pub mod request; +pub mod ping; +pub mod delete; +pub mod update; +pub mod extend; +pub mod event; use { - paste::paste, - propose::{SessionProposeRequest, SessionProposeResponse}, - regex::Regex, - serde::{Deserialize, Serialize}, - serde_json::Value, - settle::SessionSettleRequest, - std::{ + delete::SessionDeleteRequest, event::SessionEventRequest, extend::SessionExtendRequest, paste::paste, propose::{SessionProposeRequest, SessionProposeResponse}, regex::Regex, request::SessionRequestRequest, serde::{Deserialize, Serialize}, serde_json::Value, settle::SessionSettleRequest, std::{ collections::{BTreeMap, BTreeSet}, ops::Deref, sync::OnceLock, - }, + }, update::SessionUpdateRequest }; /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces @@ -291,7 +291,12 @@ macro_rules! impl_relay_protocol_metadata { match self { [<$param_type>]::SessionPropose(_) => propose::[], [<$param_type>]::SessionSettle(_) => propose::[], - + [<$param_type>]::SessionRequest(_) => propose::[], + [<$param_type>]::SessionUpdate(_) => propose::[], + [<$param_type>]::SessionDelete(_) => propose::[], + [<$param_type>]::SessionEvent(_) => propose::[], + [<$param_type>]::SessionExtend(_) => propose::[], + [<$param_type>]::SessionPing(_) => propose::[], } } } @@ -312,6 +317,16 @@ macro_rules! impl_relay_protocol_helpers { Ok(Self::SessionPropose(serde_json::from_value(value)?)) } else if tag == settle::IRN_RESPONSE_METADATA.tag { Ok(Self::SessionSettle(serde_json::from_value(value)?)) + } else if tag == request::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionRequest(serde_json::from_value(value)?)) + } else if tag == delete::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionDelete(serde_json::from_value(value)?)) + } else if tag == extend::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionExtend(serde_json::from_value(value)?)) + } else if tag == update::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionUpdate(serde_json::from_value(value)?)) + } else if tag == event::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionEvent(serde_json::from_value(value)?)) } else { Err(ParamsError::ResponseTag(tag)) } @@ -332,18 +347,18 @@ pub enum RequestParams { 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(()), + #[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); @@ -374,12 +389,12 @@ pub enum ResponseParams { pub enum ResponseParamsSuccess { SessionPropose(SessionProposeResponse), SessionSettle(bool), - // SessionUpdate(bool), - // SessionExtend(bool), - // SessionRequest(bool), - // SessionEvent(bool), - // SessionDelete(bool), - // SessionPing(bool), + SessionUpdate(bool), + SessionExtend(bool), + SessionRequest(bool), + SessionEvent(bool), + SessionDelete(bool), + SessionPing(bool), } impl_relay_protocol_metadata!(ResponseParamsSuccess, response); impl_relay_protocol_helpers!(ResponseParamsSuccess); @@ -412,12 +427,12 @@ pub struct ErrorParams { pub enum ResponseParamsError { SessionPropose(ErrorParams), SessionSettle(ErrorParams), - // SessionUpdate(ErrorParams), - // SessionExtend(ErrorParams), - // SessionRequest(ErrorParams), - // SessionEvent(ErrorParams), - // SessionDelete(ErrorParams), - // SessionPing(ErrorParams), + SessionUpdate(ErrorParams), + SessionExtend(ErrorParams), + SessionRequest(ErrorParams), + SessionEvent(ErrorParams), + SessionDelete(ErrorParams), + SessionPing(ErrorParams), } impl_relay_protocol_metadata!(ResponseParamsError, response); impl_relay_protocol_helpers!(ResponseParamsError); diff --git a/sign_api/examples/session.rs b/sign_api/examples/session.rs index 3edb62c..75a3877 100644 --- a/sign_api/examples/session.rs +++ b/sign_api/examples/session.rs @@ -3,7 +3,7 @@ use { chrono::Utc, clap::Parser, relay_client::{ - error::{ClientError, Error}, + error::ClientError, websocket::{Client, CloseFrame, ConnectionHandler, PublishedMessage}, ConnectionOptions, MessageIdGenerator, @@ -14,16 +14,7 @@ use { rpc::{ params::{ session::{ - propose::{SessionProposeRequest, SessionProposeResponse}, - settle::{Controller, SessionSettleRequest}, - IrnMetadata, - ProposeNamespace, - ProposeNamespaces, - RelayProtocolMetadata, - RequestParams, - ResponseParamsSuccess, - SettleNamespace, - SettleNamespaces, + delete::SessionDeleteRequest, propose::{SessionProposeRequest, SessionProposeResponse}, settle::{Controller, SessionSettleRequest}, IrnMetadata, ProposeNamespace, ProposeNamespaces, RelayProtocolMetadata, RequestParams, ResponseParamsSuccess, SettleNamespace, SettleNamespaces }, Metadata, Relay, @@ -222,14 +213,14 @@ async fn process_proposal_request( Ok(create_proposal_response(responder_public_key)) } -// fn process_session_delete_request(delete_params: SessionDeleteRequest) -> -// ResponseParamsSuccess { println!( -// "\nSession is being terminated reason={}, code={}", -// delete_params.message, delete_params.code, -// ); +fn process_session_delete_request(delete_params: SessionDeleteRequest) -> +ResponseParamsSuccess { println!( + "\nSession is being terminated reason={}, code={}", + delete_params.message, delete_params.code, + ); -// ResponseParamsSuccess::SessionDelete(true) -// } + ResponseParamsSuccess::SessionDelete(true) +} async fn process_inbound_request( context: Arc>, @@ -241,11 +232,17 @@ async fn process_inbound_request( Params::SessionPropose(proposal) => { process_proposal_request(context.clone(), proposal).await? } - // RequestParams::SessionDelete(params) => { - // session_delete_cleanup_required = Some(topic.clone()); - // process_session_delete_request(params) - // } - // RequestParams::SessionPing(_) => ResponseParamsSuccess::SessionPing(true), + Params::SessionRequest(request) => { + println!("params: {}", request.request.params); + println!("method: {}", request.request.method); + + todo!() + } + Params::SessionDelete(params) => { + session_delete_cleanup_required = Some(topic.clone()); + process_session_delete_request(params) + } + Params::SessionPing(_) => ResponseParamsSuccess::SessionPing(true), _ => todo!(), }; @@ -255,9 +252,9 @@ async fn process_inbound_request( .await?; // Corner case after the session was closed by the dapp. - // if let Some(topic) = session_delete_cleanup_required { - // context.session_delete_cleanup(topic).await? - // } + if let Some(topic) = session_delete_cleanup_required { + context.session_delete_cleanup(topic).await? + } Ok(()) } @@ -440,6 +437,41 @@ impl Context { Ok(()) } + + /// Deletes session identified by the `topic`. + /// + /// When session count reaches zero, unsubscribes from topic and sends + /// termination signal to end the application execution. + /// + /// TODO: should really delete pairing as well: + /// https://specs.walletconnect.com/2.0/specs/clients/core/pairing/ + /// rpc-methods#wc_pairingdelete + async fn session_delete_cleanup(&mut self, topic: Topic) -> Result<()> { + let _session = self + .sessions + .remove(&topic) + .ok_or_else(|| anyhow::anyhow!("Attempt to remove non-existing session"))?; + + self.client + .unsubscribe(topic) + .await?; + + // Un-pair when there are no more session subscriptions. + // TODO: Delete pairing, not just unsubscribe. + if self.sessions.is_empty() { + println!("\nNo active sessions left, terminating the pairing"); + + self.client + .unsubscribe( + self.pairing.topic.clone(), + ) + .await?; + + self.pairing.terminator.send(()).await?; + } + + Ok(()) + } } #[tokio::main] diff --git a/sign_api/src/pairing_uri.rs b/sign_api/src/pairing_uri.rs index d833131..ab7190b 100644 --- a/sign_api/src/pairing_uri.rs +++ b/sign_api/src/pairing_uri.rs @@ -15,11 +15,7 @@ // pairing is considered expired, should be generated 5 minutes in the future use { - lazy_static::lazy_static, - regex::Regex, - std::{collections::HashMap, str::FromStr}, - thiserror::Error, - url::Url, + lazy_static::lazy_static, regex::Regex, relay_rpc::domain::Topic, std::{collections::HashMap, str::FromStr}, thiserror::Error, url::Url }; lazy_static! { @@ -114,9 +110,6 @@ impl Pairing { } } - let mapp = params.keys(); - println!("{mapp:?}"); - let relay_protocol = params .remove("relay-protocol") .ok_or(ParseError::RelayProtocolNotFound)?; From 4e42f2a4d29743dea9f9caad8d222b480c69f2ee Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 28 Aug 2024 11:50:38 +0100 Subject: [PATCH 08/18] include other session rpc methods/params --- relay_rpc/src/rpc/params/session/delete.rs | 44 +++++++++++++ relay_rpc/src/rpc/params/session/event.rs | 60 +++++++++++++++++ relay_rpc/src/rpc/params/session/extend.rs | 39 +++++++++++ relay_rpc/src/rpc/params/session/ping.rs | 36 +++++++++++ relay_rpc/src/rpc/params/session/request.rs | 71 +++++++++++++++++++++ relay_rpc/src/rpc/params/session/update.rs | 62 ++++++++++++++++++ 6 files changed, 312 insertions(+) create mode 100644 relay_rpc/src/rpc/params/session/delete.rs create mode 100644 relay_rpc/src/rpc/params/session/event.rs create mode 100644 relay_rpc/src/rpc/params/session/extend.rs create mode 100644 relay_rpc/src/rpc/params/session/ping.rs create mode 100644 relay_rpc/src/rpc/params/session/request.rs create mode 100644 relay_rpc/src/rpc/params/session/update.rs diff --git a/relay_rpc/src/rpc/params/session/delete.rs b/relay_rpc/src/rpc/params/session/delete.rs new file mode 100644 index 0000000..fc8927b --- /dev/null +++ b/relay_rpc/src/rpc/params/session/delete.rs @@ -0,0 +1,44 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessiondelete + +use super::IrnMetadata; +use serde::{Deserialize, Serialize}; + +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, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionDeleteRequest { + pub code: i64, + pub message: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + use super::super::tests::param_serde_test; + use anyhow::Result; + + #[test] + fn test_serde_session_delete_request() -> Result<()> { + let json = r#" + { + "code": 1675757972688031, + "message": "some message" + } + "#; + + param_serde_test::(json) + } +} diff --git a/relay_rpc/src/rpc/params/session/event.rs b/relay_rpc/src/rpc/params/session/event.rs new file mode 100644 index 0000000..d2f067e --- /dev/null +++ b/relay_rpc/src/rpc/params/session/event.rs @@ -0,0 +1,60 @@ +//! 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, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Event { + name: String, + /// Opaque blockchain RPC data. + /// + /// Parsing is deferred to a higher level, blockchain RPC aware code. + data: serde_json::Value, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionEventRequest { + event: Event, + chain_id: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + use super::super::tests::param_serde_test; + use anyhow::Result; + + #[test] + fn test_serde_accounts_changed_event() -> Result<()> { + // https://specs.walletconnect.com/2.0/specs/clients/sign/ + // session-events#session_event + let json = r#" + { + "event": { + "name": "accountsChanged", + "data": ["0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb"] + }, + "chainId": "eip155:5" + } + "#; + + param_serde_test::(json) + } +} diff --git a/relay_rpc/src/rpc/params/session/extend.rs b/relay_rpc/src/rpc/params/session/extend.rs new file mode 100644 index 0000000..08899aa --- /dev/null +++ b/relay_rpc/src/rpc/params/session/extend.rs @@ -0,0 +1,39 @@ +//! 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, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionExtendRequest { + pub expiry: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + use super::super::tests::param_serde_test; + use anyhow::Result; + + #[test] + fn test_serde_session_extend_request() -> Result<()> { + let json = r#"{"expiry": 86400}"#; + + param_serde_test::(json) + } +} diff --git a/relay_rpc/src/rpc/params/session/ping.rs b/relay_rpc/src/rpc/params/session/ping.rs new file mode 100644 index 0000000..4ff23da --- /dev/null +++ b/relay_rpc/src/rpc/params/session/ping.rs @@ -0,0 +1,36 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionping +//! +use super::IrnMetadata; +use serde::{Deserialize, Serialize}; + +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, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionPingRequest {} + +#[cfg(test)] +mod tests { + use super::*; + + use super::super::tests::param_serde_test; + use anyhow::Result; + + #[test] + fn test_serde_session_ping_request() -> Result<()> { + let json = r#"{}"#; + + param_serde_test::(json) + } +} diff --git a/relay_rpc/src/rpc/params/session/request.rs b/relay_rpc/src/rpc/params/session/request.rs new file mode 100644 index 0000000..3121d9d --- /dev/null +++ b/relay_rpc/src/rpc/params/session/request.rs @@ -0,0 +1,71 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionrequest + +use super::IrnMetadata; +use serde::{Deserialize, Serialize}; + +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, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub method: String, + /// Opaque blockchain RPC parameters. + /// + /// Parsing is deferred to a higher level, blockchain RPC aware code. + pub params: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub expiry: Option, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionRequestRequest { + pub request: Request, + pub chain_id: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + use super::super::tests::param_serde_test; + use anyhow::Result; + + #[test] + fn test_serde_eth_sign_transaction() -> Result<()> { + // https://specs.walletconnect.com/2.0/specs/clients/sign/ + // session-events#session_request + let json = r#" + { + "request": { + "method": "eth_signTransaction", + "params": [ + { + "from": "0x1456225dE90927193F7A171E64a600416f96f2C8", + "to": "0x1456225dE90927193F7A171E64a600416f96f2C8", + "data": "0x", + "nonce": "0x00", + "gasPrice": "0xa72c", + "gasLimit": "0x5208", + "value": "0x00" + } + ] + }, + "chainId": "eip155:5" + } + "#; + + param_serde_test::(json) + } +} diff --git a/relay_rpc/src/rpc/params/session/update.rs b/relay_rpc/src/rpc/params/session/update.rs new file mode 100644 index 0000000..b64b60c --- /dev/null +++ b/relay_rpc/src/rpc/params/session/update.rs @@ -0,0 +1,62 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionupdate + +use super::{IrnMetadata, SettleNamespaces}; +use serde::{Deserialize, Serialize}; + +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, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionUpdateRequest { + pub namespaces: SettleNamespaces, +} + +#[cfg(test)] +mod tests { + use super::*; + + use super::super::tests::param_serde_test; + use anyhow::Result; + + #[test] + fn test_serde_session_update_request() -> Result<()> { + // https://specs.walletconnect.com/2.0/specs/clients/sign/ + // session-events#session_update + let json = r#" + { + "namespaces": { + "eip155": { + "accounts": [ + "eip155:137:0x1456225dE90927193F7A171E64a600416f96f2C8", + "eip155:5:0x1456225dE90927193F7A171E64a600416f96f2C8" + ], + "methods": [ + "eth_sendTransaction", + "eth_sign", + "eth_signTransaction", + "eth_signTypedData", + "personal_sign" + ], + "events": [ + "accountsChanged", + "chainChanged" + ] + } + } + } + "#; + + param_serde_test::(json) + } +} From 23bb9257c9be52f077a21b920650412bc4084dbc Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 28 Aug 2024 12:37:51 +0100 Subject: [PATCH 09/18] rename branch and fix clippy --- Cargo.toml | 4 +- relay_rpc/src/rpc.rs | 9 ++- relay_rpc/src/rpc/params/session.rs | 25 +++++-- relay_rpc/src/rpc/params/session/delete.rs | 14 ++-- relay_rpc/src/rpc/params/session/event.rs | 15 ++-- relay_rpc/src/rpc/params/session/extend.rs | 15 ++-- relay_rpc/src/rpc/params/session/ping.rs | 15 ++-- relay_rpc/src/rpc/params/session/request.rs | 20 +++--- relay_rpc/src/rpc/params/session/update.rs | 14 ++-- sign_api/examples/session.rs | 76 +++++++++++---------- sign_api/src/pairing_uri.rs | 6 +- src/lib.rs | 2 + 12 files changed, 126 insertions(+), 89 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7dbefd1..d5acb54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,11 +7,11 @@ authors = ["WalletConnect Team"] license = "Apache-2.0" [workspace] -members = ["blockchain_api", "sign_api", "relay_client", "relay_rpc"] +members = ["blockchain_api", "relay_client", "relay_rpc", "sign_api"] [features] default = ["full"] -full = ["client", "rpc", "http"] +full = ["client", "rpc", "http", "sign_api"] client = ["dep:relay_client"] http = ["relay_client/http"] rpc = ["dep:relay_rpc"] diff --git a/relay_rpc/src/rpc.rs b/relay_rpc/src/rpc.rs index 607ca53..c25421b 100644 --- a/relay_rpc/src/rpc.rs +++ b/relay_rpc/src/rpc.rs @@ -4,7 +4,14 @@ use { crate::domain::{DidKey, MessageId, SubscriptionId, Topic}, params::session::{ - delete::SessionDeleteRequest, event::SessionEventRequest, extend::SessionExtendRequest, ping::SessionPingRequest, propose::SessionProposeRequest, request::SessionRequestRequest, settle::SessionSettleRequest, update::SessionUpdateRequest, RequestParams + delete::SessionDeleteRequest, + event::SessionEventRequest, + extend::SessionExtendRequest, + propose::SessionProposeRequest, + request::SessionRequestRequest, + settle::SessionSettleRequest, + update::SessionUpdateRequest, + RequestParams, }, serde::{de::DeserializeOwned, Deserialize, Serialize}, std::{fmt::Debug, sync::Arc}, diff --git a/relay_rpc/src/rpc/params/session.rs b/relay_rpc/src/rpc/params/session.rs index b7b0570..6fa0d39 100644 --- a/relay_rpc/src/rpc/params/session.rs +++ b/relay_rpc/src/rpc/params/session.rs @@ -1,18 +1,29 @@ +pub mod delete; +pub mod event; +pub mod extend; +pub mod ping; pub mod propose; -pub mod settle; pub mod request; -pub mod ping; -pub mod delete; +pub mod settle; pub mod update; -pub mod extend; -pub mod event; use { - delete::SessionDeleteRequest, event::SessionEventRequest, extend::SessionExtendRequest, paste::paste, propose::{SessionProposeRequest, SessionProposeResponse}, regex::Regex, request::SessionRequestRequest, serde::{Deserialize, Serialize}, serde_json::Value, settle::SessionSettleRequest, std::{ + delete::SessionDeleteRequest, + event::SessionEventRequest, + extend::SessionExtendRequest, + paste::paste, + propose::{SessionProposeRequest, SessionProposeResponse}, + regex::Regex, + request::SessionRequestRequest, + serde::{Deserialize, Serialize}, + serde_json::Value, + settle::SessionSettleRequest, + std::{ collections::{BTreeMap, BTreeSet}, ops::Deref, sync::OnceLock, - }, update::SessionUpdateRequest + }, + update::SessionUpdateRequest, }; /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces diff --git a/relay_rpc/src/rpc/params/session/delete.rs b/relay_rpc/src/rpc/params/session/delete.rs index fc8927b..d31deac 100644 --- a/relay_rpc/src/rpc/params/session/delete.rs +++ b/relay_rpc/src/rpc/params/session/delete.rs @@ -1,8 +1,10 @@ //! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods //! #wc_sessiondelete -use super::IrnMetadata; -use serde::{Deserialize, Serialize}; +use { + super::IrnMetadata, + serde::{Deserialize, Serialize}, +}; pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { tag: 1112, @@ -25,10 +27,10 @@ pub struct SessionDeleteRequest { #[cfg(test)] mod tests { - use super::*; - - use super::super::tests::param_serde_test; - use anyhow::Result; + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; #[test] fn test_serde_session_delete_request() -> Result<()> { diff --git a/relay_rpc/src/rpc/params/session/event.rs b/relay_rpc/src/rpc/params/session/event.rs index d2f067e..bb4691d 100644 --- a/relay_rpc/src/rpc/params/session/event.rs +++ b/relay_rpc/src/rpc/params/session/event.rs @@ -1,9 +1,10 @@ //! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods //! #wc_sessionevent -use serde::{Deserialize, Serialize}; - -use super::IrnMetadata; +use { + super::IrnMetadata, + serde::{Deserialize, Serialize}, +}; pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { tag: 1110, @@ -36,10 +37,10 @@ pub struct SessionEventRequest { #[cfg(test)] mod tests { - use super::*; - - use super::super::tests::param_serde_test; - use anyhow::Result; + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; #[test] fn test_serde_accounts_changed_event() -> Result<()> { diff --git a/relay_rpc/src/rpc/params/session/extend.rs b/relay_rpc/src/rpc/params/session/extend.rs index 08899aa..cb12200 100644 --- a/relay_rpc/src/rpc/params/session/extend.rs +++ b/relay_rpc/src/rpc/params/session/extend.rs @@ -1,9 +1,10 @@ //! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods //! #wc_sessionextend -use serde::{Deserialize, Serialize}; - -use super::IrnMetadata; +use { + super::IrnMetadata, + serde::{Deserialize, Serialize}, +}; pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { tag: 1106, @@ -25,10 +26,10 @@ pub struct SessionExtendRequest { #[cfg(test)] mod tests { - use super::*; - - use super::super::tests::param_serde_test; - use anyhow::Result; + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; #[test] fn test_serde_session_extend_request() -> Result<()> { diff --git a/relay_rpc/src/rpc/params/session/ping.rs b/relay_rpc/src/rpc/params/session/ping.rs index 4ff23da..a20aac0 100644 --- a/relay_rpc/src/rpc/params/session/ping.rs +++ b/relay_rpc/src/rpc/params/session/ping.rs @@ -1,8 +1,9 @@ //! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods //! #wc_sessionping -//! -use super::IrnMetadata; -use serde::{Deserialize, Serialize}; +use { + super::IrnMetadata, + serde::{Deserialize, Serialize}, +}; pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { tag: 1114, @@ -22,10 +23,10 @@ pub struct SessionPingRequest {} #[cfg(test)] mod tests { - use super::*; - - use super::super::tests::param_serde_test; - use anyhow::Result; + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; #[test] fn test_serde_session_ping_request() -> Result<()> { diff --git a/relay_rpc/src/rpc/params/session/request.rs b/relay_rpc/src/rpc/params/session/request.rs index 3121d9d..9e62f5c 100644 --- a/relay_rpc/src/rpc/params/session/request.rs +++ b/relay_rpc/src/rpc/params/session/request.rs @@ -1,8 +1,10 @@ //! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods //! #wc_sessionrequest -use super::IrnMetadata; -use serde::{Deserialize, Serialize}; +use { + super::IrnMetadata, + serde::{Deserialize, Serialize}, +}; pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { tag: 1108, @@ -19,13 +21,13 @@ pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { #[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct Request { - pub method: String, + pub method: String, /// Opaque blockchain RPC parameters. /// /// Parsing is deferred to a higher level, blockchain RPC aware code. - pub params: serde_json::Value, + pub params: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] - pub expiry: Option, + pub expiry: Option, } #[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] @@ -37,10 +39,10 @@ pub struct SessionRequestRequest { #[cfg(test)] mod tests { - use super::*; - - use super::super::tests::param_serde_test; - use anyhow::Result; + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; #[test] fn test_serde_eth_sign_transaction() -> Result<()> { diff --git a/relay_rpc/src/rpc/params/session/update.rs b/relay_rpc/src/rpc/params/session/update.rs index b64b60c..085061e 100644 --- a/relay_rpc/src/rpc/params/session/update.rs +++ b/relay_rpc/src/rpc/params/session/update.rs @@ -1,8 +1,10 @@ //! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods //! #wc_sessionupdate -use super::{IrnMetadata, SettleNamespaces}; -use serde::{Deserialize, Serialize}; +use { + super::{IrnMetadata, SettleNamespaces}, + serde::{Deserialize, Serialize}, +}; pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { tag: 1104, @@ -24,10 +26,10 @@ pub struct SessionUpdateRequest { #[cfg(test)] mod tests { - use super::*; - - use super::super::tests::param_serde_test; - use anyhow::Result; + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; #[test] fn test_serde_session_update_request() -> Result<()> { diff --git a/sign_api/examples/session.rs b/sign_api/examples/session.rs index 75a3877..5e878f2 100644 --- a/sign_api/examples/session.rs +++ b/sign_api/examples/session.rs @@ -14,7 +14,17 @@ use { rpc::{ params::{ session::{ - delete::SessionDeleteRequest, propose::{SessionProposeRequest, SessionProposeResponse}, settle::{Controller, SessionSettleRequest}, IrnMetadata, ProposeNamespace, ProposeNamespaces, RelayProtocolMetadata, RequestParams, ResponseParamsSuccess, SettleNamespace, SettleNamespaces + delete::SessionDeleteRequest, + propose::{SessionProposeRequest, SessionProposeResponse}, + settle::{Controller, SessionSettleRequest}, + IrnMetadata, + ProposeNamespace, + ProposeNamespaces, + RelayProtocolMetadata, + RequestParams, + ResponseParamsSuccess, + SettleNamespace, + SettleNamespaces, }, Metadata, Relay, @@ -213,8 +223,8 @@ async fn process_proposal_request( Ok(create_proposal_response(responder_public_key)) } -fn process_session_delete_request(delete_params: SessionDeleteRequest) -> -ResponseParamsSuccess { println!( +fn process_session_delete_request(delete_params: SessionDeleteRequest) -> ResponseParamsSuccess { + println!( "\nSession is being terminated reason={}, code={}", delete_params.message, delete_params.code, ); @@ -439,39 +449,33 @@ impl Context { } /// Deletes session identified by the `topic`. - /// - /// When session count reaches zero, unsubscribes from topic and sends - /// termination signal to end the application execution. - /// - /// TODO: should really delete pairing as well: - /// https://specs.walletconnect.com/2.0/specs/clients/core/pairing/ - /// rpc-methods#wc_pairingdelete - async fn session_delete_cleanup(&mut self, topic: Topic) -> Result<()> { - let _session = self - .sessions - .remove(&topic) - .ok_or_else(|| anyhow::anyhow!("Attempt to remove non-existing session"))?; - - self.client - .unsubscribe(topic) - .await?; - - // Un-pair when there are no more session subscriptions. - // TODO: Delete pairing, not just unsubscribe. - if self.sessions.is_empty() { - println!("\nNo active sessions left, terminating the pairing"); - - self.client - .unsubscribe( - self.pairing.topic.clone(), - ) - .await?; - - self.pairing.terminator.send(()).await?; - } - - Ok(()) - } + /// + /// When session count reaches zero, unsubscribes from topic and sends + /// termination signal to end the application execution. + /// + /// TODO: should really delete pairing as well: + /// https://specs.walletconnect.com/2.0/specs/clients/core/pairing/ + /// rpc-methods#wc_pairingdelete + async fn session_delete_cleanup(&mut self, topic: Topic) -> Result<()> { + let _session = self + .sessions + .remove(&topic) + .ok_or_else(|| anyhow::anyhow!("Attempt to remove non-existing session"))?; + + self.client.unsubscribe(topic).await?; + + // Un-pair when there are no more session subscriptions. + // TODO: Delete pairing, not just unsubscribe. + if self.sessions.is_empty() { + println!("\nNo active sessions left, terminating the pairing"); + + self.client.unsubscribe(self.pairing.topic.clone()).await?; + + self.pairing.terminator.send(()).await?; + } + + Ok(()) + } } #[tokio::main] diff --git a/sign_api/src/pairing_uri.rs b/sign_api/src/pairing_uri.rs index ab7190b..3c46775 100644 --- a/sign_api/src/pairing_uri.rs +++ b/sign_api/src/pairing_uri.rs @@ -15,7 +15,11 @@ // pairing is considered expired, should be generated 5 minutes in the future use { - lazy_static::lazy_static, regex::Regex, relay_rpc::domain::Topic, std::{collections::HashMap, str::FromStr}, thiserror::Error, url::Url + lazy_static::lazy_static, + regex::Regex, + std::{collections::HashMap, str::FromStr}, + thiserror::Error, + url::Url, }; lazy_static! { diff --git a/src/lib.rs b/src/lib.rs index 03b8a93..b301a91 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,3 +2,5 @@ pub use relay_client as client; #[cfg(feature = "rpc")] pub use relay_rpc as rpc; +#[cfg(feature = "sign_api")] +pub use sign_api; From 325ca69d22639a1f85eccc8f402b7c4debfd5c68 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 28 Aug 2024 12:58:47 +0100 Subject: [PATCH 10/18] make sign_api WASM compat --- relay_client/src/websocket.rs | 2 +- relay_rpc/Cargo.toml | 4 ++-- relay_rpc/src/rpc.rs | 2 +- sign_api/Cargo.toml | 4 ++++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/relay_client/src/websocket.rs b/relay_client/src/websocket.rs index cd59aaf..06c3008 100644 --- a/relay_client/src/websocket.rs +++ b/relay_client/src/websocket.rs @@ -1,5 +1,5 @@ #[cfg(not(target_arch = "wasm32"))] -use tokio::task::spawn; +use tokio::spawn; #[cfg(target_arch = "wasm32")] use wasm_bindgen_futures::spawn_local as spawn; use { diff --git a/relay_rpc/Cargo.toml b/relay_rpc/Cargo.toml index 5ed910d..dfb600f 100644 --- a/relay_rpc/Cargo.toml +++ b/relay_rpc/Cargo.toml @@ -17,7 +17,7 @@ cacao = [ "dep:alloy-sol-types", "dep:alloy-primitives", "dep:alloy-node-bindings", - "dep:alloy-contract", + "dep:alloy-contract" ] [dependencies] @@ -29,8 +29,8 @@ derive_more = { version = "0.99", default-features = false, features = [ "as_ref", "as_mut", ] } -serde-aux = { version = "4.1", default-features = false } serde = { version = "1.0", features = ["derive", "rc"] } +serde-aux = { version = "4.1", default-features = false } serde_json = "1.0" thiserror = "1.0" ed25519-dalek = { version = "2.1.1", features = ["rand_core"] } diff --git a/relay_rpc/src/rpc.rs b/relay_rpc/src/rpc.rs index c25421b..340f12d 100644 --- a/relay_rpc/src/rpc.rs +++ b/relay_rpc/src/rpc.rs @@ -902,6 +902,7 @@ impl Request { return Err(PayloadError::InvalidJsonRpcVersion); } + // TODO: add validation checks for Session Params match &self.params { Params::Subscribe(params) => params.validate(), Params::SubscribeBlocking(params) => params.validate(), @@ -916,7 +917,6 @@ impl Request { Params::WatchRegister(params) => params.validate(), Params::WatchUnregister(params) => params.validate(), Params::Subscription(params) => params.validate(), - // Params::SessionPropose(params) => params.vl _ => Ok(()), } } diff --git a/sign_api/Cargo.toml b/sign_api/Cargo.toml index 6184fb7..50b5ed8 100644 --- a/sign_api/Cargo.toml +++ b/sign_api/Cargo.toml @@ -26,6 +26,8 @@ thiserror = "1.0" url = "2.3" x25519-dalek = { version = "2.0", features = ["static_secrets"] } rand = { version = "0.8" } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "1.22", features = [ "rt", "rt-multi-thread", @@ -33,6 +35,8 @@ tokio = { version = "1.22", features = [ "macros", ] } +[target.'cfg(target_arch = "wasm32")'.dependencies] +tokio = { version = "1.22", features = ["sync", "macros"] } [[example]] name = "session" From 15a6dbfe42f456bbbcc89ce00deb1fab7b5cda59 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 28 Aug 2024 13:13:31 +0100 Subject: [PATCH 11/18] minor changes --- sign_api/src/lib.rs | 5 +-- sign_api/src/session.rs | 73 ----------------------------------------- 2 files changed, 3 insertions(+), 75 deletions(-) delete mode 100644 sign_api/src/session.rs diff --git a/sign_api/src/lib.rs b/sign_api/src/lib.rs index a37d6dd..06dc148 100644 --- a/sign_api/src/lib.rs +++ b/sign_api/src/lib.rs @@ -1,8 +1,9 @@ -pub mod crypto; +mod crypto; mod pairing_uri; -pub mod session; +mod session_key; pub use { crypto::*, pairing_uri::{Pairing, PairingParams}, + session_key::* }; diff --git a/sign_api/src/session.rs b/sign_api/src/session.rs deleted file mode 100644 index 26a9341..0000000 --- a/sign_api/src/session.rs +++ /dev/null @@ -1,73 +0,0 @@ -use { - hkdf::Hkdf, - rand::{rngs::OsRng, CryptoRng, RngCore}, - sha2::{Digest, Sha256}, - std::fmt::Debug, - x25519_dalek::{EphemeralSecret, PublicKey}, -}; - -/// Session key and topic derivation errors. -#[derive(Debug, thiserror::Error)] -pub enum SessionError { - #[error("Failed to generate symmetric session key: {0}")] - SymKeyGeneration(String), -} - -pub struct SessionKey { - sym_key: [u8; 32], - public_key: PublicKey, -} - -impl std::fmt::Debug for SessionKey { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SessionKey") - .field("sym_key", &"*******") - .field("public_key", &self.public_key) - .finish() - } -} - -impl SessionKey { - /// Creates new session key from `osrng`. - 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| SessionError::SymKeyGeneration(e.to_string()))?; - - 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()) - } -} From c6c2c5ce99181d112d7afe5d25dab2021837d136 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 28 Aug 2024 13:13:59 +0100 Subject: [PATCH 12/18] rename session.rs -> session_key.rs --- sign_api/src/session_key.rs | 73 +++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 sign_api/src/session_key.rs diff --git a/sign_api/src/session_key.rs b/sign_api/src/session_key.rs new file mode 100644 index 0000000..26a9341 --- /dev/null +++ b/sign_api/src/session_key.rs @@ -0,0 +1,73 @@ +use { + hkdf::Hkdf, + rand::{rngs::OsRng, CryptoRng, RngCore}, + sha2::{Digest, Sha256}, + std::fmt::Debug, + x25519_dalek::{EphemeralSecret, PublicKey}, +}; + +/// Session key and topic derivation errors. +#[derive(Debug, thiserror::Error)] +pub enum SessionError { + #[error("Failed to generate symmetric session key: {0}")] + SymKeyGeneration(String), +} + +pub struct SessionKey { + sym_key: [u8; 32], + public_key: PublicKey, +} + +impl std::fmt::Debug for SessionKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionKey") + .field("sym_key", &"*******") + .field("public_key", &self.public_key) + .finish() + } +} + +impl SessionKey { + /// Creates new session key from `osrng`. + 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| SessionError::SymKeyGeneration(e.to_string()))?; + + 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()) + } +} From f9cebee741ffdf0d65abfa4eb4126b8a26bbfc52 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 28 Aug 2024 13:34:53 +0100 Subject: [PATCH 13/18] fix minor bugs and clippy --- relay_rpc/src/rpc/params/session.rs | 56 +++++++++++++++++------------ sign_api/examples/session.rs | 10 ++++-- sign_api/src/lib.rs | 2 +- sign_api/src/pairing_uri.rs | 3 ++ 4 files changed, 44 insertions(+), 27 deletions(-) diff --git a/relay_rpc/src/rpc/params/session.rs b/relay_rpc/src/rpc/params/session.rs index 6fa0d39..a2a586e 100644 --- a/relay_rpc/src/rpc/params/session.rs +++ b/relay_rpc/src/rpc/params/session.rs @@ -301,13 +301,13 @@ macro_rules! impl_relay_protocol_metadata { fn irn_metadata(&self) -> IrnMetadata { match self { [<$param_type>]::SessionPropose(_) => propose::[], - [<$param_type>]::SessionSettle(_) => propose::[], - [<$param_type>]::SessionRequest(_) => propose::[], - [<$param_type>]::SessionUpdate(_) => propose::[], - [<$param_type>]::SessionDelete(_) => propose::[], - [<$param_type>]::SessionEvent(_) => propose::[], - [<$param_type>]::SessionExtend(_) => propose::[], - [<$param_type>]::SessionPing(_) => propose::[], + [<$param_type>]::SessionSettle(_) => settle::[], + [<$param_type>]::SessionRequest(_) => request::[], + [<$param_type>]::SessionUpdate(_) => update::[], + [<$param_type>]::SessionDelete(_) => delete::[], + [<$param_type>]::SessionEvent(_) => event::[], + [<$param_type>]::SessionExtend(_) => extend::[], + [<$param_type>]::SessionPing(_) => ping::[], } } } @@ -324,22 +324,32 @@ macro_rules! impl_relay_protocol_helpers { type Params = Self; fn irn_try_from_tag(value: Value, tag: u32) -> Result { - if tag == propose::IRN_RESPONSE_METADATA.tag { - Ok(Self::SessionPropose(serde_json::from_value(value)?)) - } else if tag == settle::IRN_RESPONSE_METADATA.tag { - Ok(Self::SessionSettle(serde_json::from_value(value)?)) - } else if tag == request::IRN_RESPONSE_METADATA.tag { - Ok(Self::SessionRequest(serde_json::from_value(value)?)) - } else if tag == delete::IRN_RESPONSE_METADATA.tag { - Ok(Self::SessionDelete(serde_json::from_value(value)?)) - } else if tag == extend::IRN_RESPONSE_METADATA.tag { - Ok(Self::SessionExtend(serde_json::from_value(value)?)) - } else if tag == update::IRN_RESPONSE_METADATA.tag { - Ok(Self::SessionUpdate(serde_json::from_value(value)?)) - } else if tag == event::IRN_RESPONSE_METADATA.tag { - Ok(Self::SessionEvent(serde_json::from_value(value)?)) - } else { - Err(ParamsError::ResponseTag(tag)) + match tag { + tag if tag == propose::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionPropose(serde_json::from_value(value)?)) + } + tag if tag == settle::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionSettle(serde_json::from_value(value)?)) + } + tag if tag == request::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionRequest(serde_json::from_value(value)?)) + } + tag if tag == delete::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionDelete(serde_json::from_value(value)?)) + } + tag if tag == extend::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionExtend(serde_json::from_value(value)?)) + } + tag if tag == update::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionUpdate(serde_json::from_value(value)?)) + } + tag if tag == event::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionEvent(serde_json::from_value(value)?)) + } + tag if tag == event::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionPing(serde_json::from_value(value)?)) + } + _ => Err(ParamsError::ResponseTag(tag)), } } } diff --git a/sign_api/examples/session.rs b/sign_api/examples/session.rs index 5e878f2..eeeddd8 100644 --- a/sign_api/examples/session.rs +++ b/sign_api/examples/session.rs @@ -38,9 +38,11 @@ use { }, }, sign_api::{ - crypto::{decode_and_decrypt_type0, encrypt_and_encode, EnvelopeType}, - session::SessionKey, + decode_and_decrypt_type0, + encrypt_and_encode, + EnvelopeType, Pairing as PairingData, + SessionKey, }, std::{ collections::{BTreeMap, HashMap}, @@ -296,7 +298,7 @@ async fn process_inbound_message( message: PublishedMessage, ) -> Result<()> { let plain = { - let mut context = context.lock().await; + let context = context.lock().await; context.peek_sym_key(&message.topic, |key| { decode_and_decrypt_type0(message.message.as_bytes(), key) .map_err(|e| anyhow::anyhow!(e)) @@ -328,6 +330,7 @@ async fn inbound_handler(context: Arc>, message: PublishedMessage } /// https://specs.walletconnect.com/2.0/specs/clients/core/pairing +#[allow(dead_code)] struct Pairing { /// Termination signal for when all sessions have been closed. terminator: Sender<()>, @@ -345,6 +348,7 @@ struct Pairing { /// https://specs.walletconnect.com/2.0/specs/clients/sign/session-proposal /// /// New session as the result of successful session proposal. +#[allow(dead_code)] struct Session { /// Pairing subscription id. subscription_id: SubscriptionId, diff --git a/sign_api/src/lib.rs b/sign_api/src/lib.rs index 06dc148..1b369f5 100644 --- a/sign_api/src/lib.rs +++ b/sign_api/src/lib.rs @@ -5,5 +5,5 @@ mod session_key; pub use { crypto::*, pairing_uri::{Pairing, PairingParams}, - session_key::* + session_key::*, }; diff --git a/sign_api/src/pairing_uri.rs b/sign_api/src/pairing_uri.rs index 3c46775..71ac564 100644 --- a/sign_api/src/pairing_uri.rs +++ b/sign_api/src/pairing_uri.rs @@ -164,7 +164,10 @@ pub enum ParseError { UnexpectedProtocol(String), } +#[cfg(test)] mod tests { + use super::*; + #[test] fn parse_uri() { let uri = "wc:c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168@2?\ From 88c42c2086d69bc6f137c076c396f2e8c15e53db Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 28 Aug 2024 13:44:28 +0100 Subject: [PATCH 14/18] fix clippy --- relay_rpc/Cargo.toml | 1 + relay_rpc/src/rpc/params/session/propose.rs | 5 +++-- sign_api/examples/session.rs | 10 ++++------ sign_api/src/pairing_uri.rs | 3 +-- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/relay_rpc/Cargo.toml b/relay_rpc/Cargo.toml index dfb600f..b0e750a 100644 --- a/relay_rpc/Cargo.toml +++ b/relay_rpc/Cargo.toml @@ -21,6 +21,7 @@ cacao = [ ] [dependencies] +anyhow = "1.0.86" bs58 = "0.4" data-encoding = "2.3" derive_more = { version = "0.99", default-features = false, features = [ diff --git a/relay_rpc/src/rpc/params/session/propose.rs b/relay_rpc/src/rpc/params/session/propose.rs index 5820f18..5292976 100644 --- a/relay_rpc/src/rpc/params/session/propose.rs +++ b/relay_rpc/src/rpc/params/session/propose.rs @@ -41,9 +41,10 @@ pub struct SessionProposeResponse { #[cfg(test)] mod tests { use super::{super::tests::param_serde_test, *}; + use anyhow::Result; #[test] - fn test_serde_session_propose_request() { + fn test_serde_session_propose_request() -> Result<()> { // https://specs.walletconnect.com/2.0/specs/clients/sign/ // session-events#session_propose let json = r#" @@ -85,6 +86,6 @@ mod tests { } "#; - param_serde_test::(json); + param_serde_test::(json) } } diff --git a/sign_api/examples/session.rs b/sign_api/examples/session.rs index eeeddd8..0c46100 100644 --- a/sign_api/examples/session.rs +++ b/sign_api/examples/session.rs @@ -146,7 +146,6 @@ fn supported_propose_namespaces() -> ProposeNamespaces { chains: SUPPORTED_CHAINS.iter().map(|c| c.to_string()).collect(), methods: SUPPORTED_METHODS.iter().map(|m| m.to_string()).collect(), events: SUPPORTED_EVENTS.iter().map(|e| e.to_string()).collect(), - ..Default::default() }); map }) @@ -159,7 +158,6 @@ fn supported_settle_namespaces() -> SettleNamespaces { accounts: SUPPORTED_ACCOUNTS.iter().map(|a| a.to_string()).collect(), methods: SUPPORTED_METHODS.iter().map(|m| m.to_string()).collect(), events: SUPPORTED_EVENTS.iter().map(|e| e.to_string()).collect(), - ..Default::default() }); map }) @@ -207,7 +205,7 @@ async fn process_proposal_request( let session_key = SessionKey::from_osrng(&sender_public_key)?; let responder_public_key = hex::encode(session_key.diffie_public_key()); - let session_topic: Topic = session_key.generate_topic().try_into()?; + let session_topic: Topic = session_key.generate_topic().into(); { let mut context = context.lock().await; @@ -396,7 +394,7 @@ impl Context { .get(topic) .ok_or_else(|| anyhow::anyhow!("Missing sym key for topic={} ", topic))?; - f(&session.session_key.symmetric_key()) + f(session.session_key.symmetric_key()) } } @@ -433,7 +431,7 @@ impl Context { payload: &str, ) -> Result<()> { let encrypted = self.peek_sym_key(&topic, |key| { - encrypt_and_encode(EnvelopeType::Type0, &payload, key).map_err(|e| anyhow::anyhow!(e)) + encrypt_and_encode(EnvelopeType::Type0, payload, key).map_err(|e| anyhow::anyhow!(e)) })?; println!("\nOutbound encrypted payload={encrypted}"); @@ -486,7 +484,7 @@ impl Context { async fn main() -> Result<()> { let args = Arg::parse(); let pairing = PairingData::from_str(&args.pairing_uri)?; - let topic: Topic = pairing.topic.try_into()?; + let topic: Topic = pairing.topic.into(); let (inbound_sender, mut inbound_receiver) = unbounded_channel(); let (terminate_sender, mut terminate_receiver) = channel::<()>(1); diff --git a/sign_api/src/pairing_uri.rs b/sign_api/src/pairing_uri.rs index 71ac564..b88338e 100644 --- a/sign_api/src/pairing_uri.rs +++ b/sign_api/src/pairing_uri.rs @@ -182,8 +182,7 @@ mod tests { sym_key: hex::decode( "7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b", ) - .unwrap() - .into(), + .unwrap(), relay_data: None, expiry_timestamp: None, }, From a3725fb3675bdc569b17a64d60d79d86c8ea03d4 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 28 Aug 2024 13:45:49 +0100 Subject: [PATCH 15/18] cargo fmt --- relay_rpc/src/rpc/params/session/propose.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/relay_rpc/src/rpc/params/session/propose.rs b/relay_rpc/src/rpc/params/session/propose.rs index 5292976..7ef7626 100644 --- a/relay_rpc/src/rpc/params/session/propose.rs +++ b/relay_rpc/src/rpc/params/session/propose.rs @@ -40,8 +40,10 @@ pub struct SessionProposeResponse { #[cfg(test)] mod tests { - use super::{super::tests::param_serde_test, *}; - use anyhow::Result; + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; #[test] fn test_serde_session_propose_request() -> Result<()> { From 2fddf8335c9e50ce74bd426199b93217154bfce3 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 28 Aug 2024 13:59:03 +0100 Subject: [PATCH 16/18] fix unit test --- relay_rpc/src/rpc/params/session/request.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/relay_rpc/src/rpc/params/session/request.rs b/relay_rpc/src/rpc/params/session/request.rs index 9e62f5c..c48026d 100644 --- a/relay_rpc/src/rpc/params/session/request.rs +++ b/relay_rpc/src/rpc/params/session/request.rs @@ -54,12 +54,12 @@ mod tests { "method": "eth_signTransaction", "params": [ { - "from": "0x1456225dE90927193F7A171E64a600416f96f2C8", - "to": "0x1456225dE90927193F7A171E64a600416f96f2C8", "data": "0x", - "nonce": "0x00", - "gasPrice": "0xa72c", + "from": "0x1456225dE90927193F7A171E64a600416f96f2C8", "gasLimit": "0x5208", + "gasPrice": "0xa72c", + "nonce": "0x00", + "to": "0x1456225dE90927193F7A171E64a600416f96f2C8", "value": "0x00" } ] From d6322234c8455b51c55ccb6ddcafc89893d7414f Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 29 Aug 2024 18:31:26 +0100 Subject: [PATCH 17/18] implement mock signing --- relay_rpc/src/rpc/params/session.rs | 2 +- sign_api/Cargo.toml | 1 + sign_api/examples/session.rs | 39 +++++++++++++++++++++++++---- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/relay_rpc/src/rpc/params/session.rs b/relay_rpc/src/rpc/params/session.rs index a2a586e..e663f2f 100644 --- a/relay_rpc/src/rpc/params/session.rs +++ b/relay_rpc/src/rpc/params/session.rs @@ -415,7 +415,7 @@ pub enum ResponseParamsSuccess { SessionRequest(bool), SessionEvent(bool), SessionDelete(bool), - SessionPing(bool), + SessionPing(bool) } impl_relay_protocol_metadata!(ResponseParamsSuccess, response); impl_relay_protocol_helpers!(ResponseParamsSuccess); diff --git a/sign_api/Cargo.toml b/sign_api/Cargo.toml index 50b5ed8..5ddf2ec 100644 --- a/sign_api/Cargo.toml +++ b/sign_api/Cargo.toml @@ -26,6 +26,7 @@ thiserror = "1.0" url = "2.3" x25519-dalek = { version = "2.0", features = ["static_secrets"] } rand = { version = "0.8" } +ethers = "2.0.14" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "1.22", features = [ diff --git a/sign_api/examples/session.rs b/sign_api/examples/session.rs index 0c46100..e1e620b 100644 --- a/sign_api/examples/session.rs +++ b/sign_api/examples/session.rs @@ -66,6 +66,7 @@ const SUPPORTED_METHODS: &[&str] = &[ "eth_sign", "personal_sign", "eth_signTypedData", + "eth_signTypedData_v4" ]; const SUPPORTED_CHAINS: &[&str] = &["eip155:1", "eip155:5"]; const SUPPORTED_EVENTS: &[&str] = &["chainChanged", "accountsChanged"]; @@ -242,11 +243,29 @@ async fn process_inbound_request( Params::SessionPropose(proposal) => { process_proposal_request(context.clone(), proposal).await? } - Params::SessionRequest(request) => { - println!("params: {}", request.request.params); - println!("method: {}", request.request.method); - - todo!() + Params::SessionRequest(param) => { + // process sign tx request here + let message = param.request.params[0].as_str().unwrap(); + let address = param.request.params[1].as_str().unwrap(); + + // For testing purposes, we'll create a mock signature + let mock_signature = mock_sign(address, message); + let context = context.lock().await; + let response = Response::Success(SuccessfulResponse { + id: request.id, + jsonrpc: JSON_RPC_VERSION_STR.into(), + result: serde_json::to_value(mock_signature).unwrap(), + }); + let payload = serde_json::to_string(&Payload::from(response))?; + println!("\nSending response topic={topic} payload={payload}"); + const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1109, + ttl: 300, + prompt: false, + }; + let _ = context.publish_payload(topic.clone(), IRN_RESPONSE_METADATA, &payload).await; + + return Ok(()); } Params::SessionDelete(params) => { session_delete_cleanup_required = Some(topic.clone()); @@ -269,6 +288,16 @@ async fn process_inbound_request( Ok(()) } +fn mock_sign(address: &str, message: &str) -> String { + // Remove '0x' prefix if present + let message = message.strip_prefix("0x").unwrap_or(message); + + // In a real implementation, we would sign the message here. + // For mocking purposes, we'll create a deterministic "signature" based on the inputs. + let mock_signature = ethers::utils::keccak256(format!("{:?}{}", address, message).as_bytes()); + format!("0x{}", hex::encode(mock_signature)) +} + fn process_inbound_response(response: Response) -> Result<()> { match response { Response::Success(value) => { From 55e79958e810bfa7ac5fa62991945ed6c30d29a9 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 30 Aug 2024 10:25:22 +0100 Subject: [PATCH 18/18] remove duplications and minor changes --- relay_rpc/src/rpc.rs | 20 ------------- relay_rpc/src/rpc/error.rs | 27 ++++++++++++++--- relay_rpc/src/rpc/params/params.rs | 20 ------------- relay_rpc/src/rpc/params/session.rs | 45 +++++++---------------------- 4 files changed, 33 insertions(+), 79 deletions(-) delete mode 100644 relay_rpc/src/rpc/params/params.rs diff --git a/relay_rpc/src/rpc.rs b/relay_rpc/src/rpc.rs index 340f12d..54f6602 100644 --- a/relay_rpc/src/rpc.rs +++ b/relay_rpc/src/rpc.rs @@ -222,26 +222,6 @@ impl ErrorResponse { } } -/// Data structure representing error response params. -#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub struct ErrorData { - /// Error code. - pub code: i32, - - /// Error message. - pub message: String, - - /// Error data, if any. - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -#[derive(Debug, thiserror::Error, strum::EnumString, strum::IntoStaticStr, PartialEq, Eq)] -pub enum SubscriptionError { - #[error("Subscriber limit exceeded")] - SubscriberLimitExceeded, -} - /// Subscription request parameters. This request does not require the /// subscription to be fully processed, and returns as soon as the server /// receives it. diff --git a/relay_rpc/src/rpc/error.rs b/relay_rpc/src/rpc/error.rs index d7f5c4c..3c12589 100644 --- a/relay_rpc/src/rpc/error.rs +++ b/relay_rpc/src/rpc/error.rs @@ -1,7 +1,26 @@ -use { - super::ErrorData, - std::fmt::{Debug, Display}, -}; +use std::fmt::{Debug, Display}; +use serde::{Deserialize, Serialize}; + + +/// Data structure representing error response params. +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorData { + /// Error code. + pub code: i32, + + /// Error message. + pub message: String, + + /// Error data, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, thiserror::Error, strum::EnumString, strum::IntoStaticStr, PartialEq, Eq)] +pub enum SubscriptionError { + #[error("Subscriber limit exceeded")] + SubscriberLimitExceeded, +} /// Provides serialization to and from string tags. This has a blanket /// implementation for all error types that derive [`strum::EnumString`] and diff --git a/relay_rpc/src/rpc/params/params.rs b/relay_rpc/src/rpc/params/params.rs deleted file mode 100644 index 21c7800..0000000 --- a/relay_rpc/src/rpc/params/params.rs +++ /dev/null @@ -1,20 +0,0 @@ -pub mod session; - -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, -} diff --git a/relay_rpc/src/rpc/params/session.rs b/relay_rpc/src/rpc/params/session.rs index e663f2f..29f2bc4 100644 --- a/relay_rpc/src/rpc/params/session.rs +++ b/relay_rpc/src/rpc/params/session.rs @@ -8,22 +8,11 @@ pub mod settle; pub mod update; use { - delete::SessionDeleteRequest, - event::SessionEventRequest, - extend::SessionExtendRequest, - paste::paste, - propose::{SessionProposeRequest, SessionProposeResponse}, - regex::Regex, - request::SessionRequestRequest, - serde::{Deserialize, Serialize}, - serde_json::Value, - settle::SessionSettleRequest, - std::{ + crate::rpc::ErrorData, delete::SessionDeleteRequest, event::SessionEventRequest, extend::SessionExtendRequest, paste::paste, propose::{SessionProposeRequest, SessionProposeResponse}, regex::Regex, request::SessionRequestRequest, serde::{Deserialize, Serialize}, serde_json::Value, settle::SessionSettleRequest, std::{ collections::{BTreeMap, BTreeSet}, ops::Deref, sync::OnceLock, - }, - update::SessionUpdateRequest, + }, update::SessionUpdateRequest }; /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces @@ -428,32 +417,18 @@ impl TryFrom for ResponseParams { } } -/// 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), + SessionPropose(ErrorData), + SessionSettle(ErrorData), + SessionUpdate(ErrorData), + SessionExtend(ErrorData), + SessionRequest(ErrorData), + SessionEvent(ErrorData), + SessionDelete(ErrorData), + SessionPing(ErrorData), } impl_relay_protocol_metadata!(ResponseParamsError, response); impl_relay_protocol_helpers!(ResponseParamsError);