From a40a366fffd11b6ebf707d272f7db5ba90f4cc8b Mon Sep 17 00:00:00 2001 From: Silvestrs Timofejevs Date: Fri, 6 Oct 2023 11:57:56 +0100 Subject: [PATCH 1/4] chore: add vscode metadata to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 088ba6b..776f245 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk + +# VScode settings +.vscode \ No newline at end of file From c2c344fd4505219d9699acfe2a35ead22692e0c7 Mon Sep 17 00:00:00 2001 From: Silvestrs Timofejevs Date: Tue, 24 Oct 2023 16:35:18 +0100 Subject: [PATCH 2/4] feat: add WalletConnect v2 Sign API This change adds WalletConnect v2 Sign API, as per: https://specs.walletconnect.com/2.0/specs/clients/sign/ Please note that although the specification is fairly thorough, there are some inconsistencies. This implementation is derived from specs, analyzing ws traffic in browsers devtools, as well as the original WalletConnect JavaScript client at: https://github.com/WalletConnect/walletconnect-monorepo Design decisions: Modularity: - RPC and crypto modules are not dependent on each other, so client implementations don't have to use their own crypto implementation. Closes: #47 --- Cargo.toml | 7 +- sign_api/Cargo.toml | 32 + sign_api/src/crypto.rs | 2 + sign_api/src/crypto/payload.rs | 303 +++++++++ sign_api/src/crypto/session.rs | 79 +++ sign_api/src/lib.rs | 3 + sign_api/src/pairing_uri.rs | 157 +++++ sign_api/src/rpc.rs | 142 +++++ sign_api/src/rpc/params.rs | 263 ++++++++ sign_api/src/rpc/params/session_delete.rs | 45 ++ sign_api/src/rpc/params/session_event.rs | 60 ++ sign_api/src/rpc/params/session_extend.rs | 39 ++ sign_api/src/rpc/params/session_ping.rs | 37 ++ sign_api/src/rpc/params/session_propose.rs | 95 +++ sign_api/src/rpc/params/session_request.rs | 72 +++ sign_api/src/rpc/params/session_settle.rs | 90 +++ sign_api/src/rpc/params/session_update.rs | 63 ++ sign_api/src/rpc/params/shared_types.rs | 27 + .../params/shared_types/propose_namespaces.rs | 579 ++++++++++++++++++ .../params/shared_types/settle_namespaces.rs | 33 + 20 files changed, 2126 insertions(+), 2 deletions(-) create mode 100644 sign_api/Cargo.toml create mode 100644 sign_api/src/crypto.rs create mode 100644 sign_api/src/crypto/payload.rs create mode 100644 sign_api/src/crypto/session.rs create mode 100644 sign_api/src/lib.rs create mode 100644 sign_api/src/pairing_uri.rs create mode 100644 sign_api/src/rpc.rs create mode 100644 sign_api/src/rpc/params.rs create mode 100644 sign_api/src/rpc/params/session_delete.rs create mode 100644 sign_api/src/rpc/params/session_event.rs create mode 100644 sign_api/src/rpc/params/session_extend.rs create mode 100644 sign_api/src/rpc/params/session_ping.rs create mode 100644 sign_api/src/rpc/params/session_propose.rs create mode 100644 sign_api/src/rpc/params/session_request.rs create mode 100644 sign_api/src/rpc/params/session_settle.rs create mode 100644 sign_api/src/rpc/params/session_update.rs create mode 100644 sign_api/src/rpc/params/shared_types.rs create mode 100644 sign_api/src/rpc/params/shared_types/propose_namespaces.rs create mode 100644 sign_api/src/rpc/params/shared_types/settle_namespaces.rs diff --git a/Cargo.toml b/Cargo.toml index 8b29593..a70043c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,18 +9,21 @@ license = "Apache-2.0" [workspace] members = [ "relay_client", - "relay_rpc" + "relay_rpc", + "sign_api" ] [features] default = ["full"] -full = ["client", "rpc"] +full = ["client", "rpc", "sign_api"] client = ["dep:relay_client"] rpc = ["dep:relay_rpc"] +sign_api = ["dep:sign_api"] [dependencies] relay_client = { path = "./relay_client", optional = true } relay_rpc = { path = "./relay_rpc", optional = true } +sign_api = { path = "./sign_api", optional = true } [dev-dependencies] anyhow = "1" diff --git a/sign_api/Cargo.toml b/sign_api/Cargo.toml new file mode 100644 index 0000000..86739e0 --- /dev/null +++ b/sign_api/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "sign_api" +version = "0.1.0" +edition = "2021" + +[dependencies] +base58 = "0.2" +base64 = "0.21" +base64-url = "2.0" +chacha20poly1305 = "0.10" +chrono = "0.4" +hex = "0.4" +hex-literal = "0.4" +hkdf = "0.12" +lazy_static = "1.4" +once_cell = "1.16" +paste = "1.0" +rand = "0.8" +regex = "1.10" +sha2 = "0.10" +serde = { version = "1.0", features = ["derive", "rc"] } +serde_json = "1.0" +thiserror = "1.0" +x25519-dalek = { version = "2.0", features = ["static_secrets"] } +url = "2.4" + +[dev-dependencies] +# Serialization/Deserealization changes order of fields. Preserving order +# makes it easier to construct the unittests. +serde_json = { version = "1.0", features = ["preserve_order"] } + +anyhow = "1" diff --git a/sign_api/src/crypto.rs b/sign_api/src/crypto.rs new file mode 100644 index 0000000..45b480b --- /dev/null +++ b/sign_api/src/crypto.rs @@ -0,0 +1,2 @@ +pub mod payload; +pub mod session; diff --git a/sign_api/src/crypto/payload.rs b/sign_api/src/crypto/payload.rs new file mode 100644 index 0000000..d0c3c5b --- /dev/null +++ b/sign_api/src/crypto/payload.rs @@ -0,0 +1,303 @@ +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; + 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::Type1 { + sender_public_key: data[key_start_index..key_end_index] + .try_into() + .map_err(|_| { + PayloadError::ParseSenderPublicKeyLen( + init_vec_start_index, + init_vec_end_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) +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use hex_literal::hex; + + use super::*; + + // https://www.rfc-editor.org/rfc/rfc7539#section-2.8.2 + // Below constans are taken from this section of the RFC. + + const PLAINTEXT: &str = r#"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."#; + const CIPHERTEXT: [u8; 114] = hex!( + "d3 1a 8d 34 64 8e 60 db 7b 86 af bc 53 ef 7e c2 + a4 ad ed 51 29 6e 08 fe a9 e2 b5 a7 36 ee 62 d6 + 3d be a4 5e 8c a9 67 12 82 fa fb 69 da 92 72 8b + 1a 71 de 0a 9e 06 0b 29 05 d6 a5 b6 7e cd 3b 36 + 92 dd bd 7f 2d 77 8b 8c 98 03 ae e3 28 09 1b 58 + fa b3 24 e4 fa d6 75 94 55 85 80 8b 48 31 d7 bc + 3f f4 de f0 8e 4b 7a 9d e5 76 d2 65 86 ce c6 4b + 61 16" + ); + const TAG: [u8; 16] = hex!("1a e1 0b 59 4f 09 e2 6a 7e 90 2e cb d0 60 06 91"); + const SYMKEY: SymKey = hex!( + "80 81 82 83 84 85 86 87 88 89 8a 8b 8c 8d 8e 8f + 90 91 92 93 94 95 96 97 98 99 9a 9b 9c 9d 9e 9f" + ); + const AAD: [u8; 12] = hex!("50 51 52 53 c0 c1 c2 c3 c4 c5 c6 c7"); + const INIT_VEC: InitVec = hex!("07 00 00 00 40 41 42 43 44 45 46 47"); + + /// Tests WCv2 encoding and decoding. + #[test] + fn test_decode_encoded() -> Result<()> { + let init_vec: &InitVec = INIT_VEC.as_slice().try_into()?; + let sealed = [CIPHERTEXT.as_slice(), TAG.as_slice()].concat(); + + let encoded = encode(EnvelopeType::Type0, &sealed, init_vec); + assert_eq!( + encoded, + "AAcAAABAQUJDREVGR9MajTRkjmDbe4avvFPvfsKkre1RKW4I/qnitac27mLWPb6kXoypZxKC+vtp2pJyixpx3gqeBgspBdaltn7NOzaS3b1/LXeLjJgDruMoCRtY+rMk5PrWdZRVhYCLSDHXvD/03vCOS3qd5XbSZYbOxkthFhrhC1lPCeJqfpAuy9BgBpE=" + ); + + let data = BASE64_STANDARD.decode(&encoded)?; + let decoded = EncodingParams::parse_decoded(&data)?; + assert_eq!(decoded.envelope_type, EnvelopeType::Type0); + assert_eq!(decoded.sealed, sealed); + assert_eq!(decoded.init_vec, init_vec); + + Ok(()) + } + + /// Tests ChaCha20-Poly1305 encryption against the RFC test vector. + /// + /// https://www.rfc-editor.org/rfc/rfc7539#section-2.8.2 + /// Please note that this test vector has an + /// "Additional Authentication Data", in practice, we will likely + /// be using this algorithm without "AAD". + #[test] + fn test_encryption() -> Result<()> { + let payload = Payload { + msg: PLAINTEXT.as_bytes(), + aad: AAD.as_slice(), + }; + let init_vec = INIT_VEC.as_slice().try_into()?; + + let sealed = encrypt(init_vec, payload, &SYMKEY)?; + assert_eq!(sealed, [CIPHERTEXT.as_slice(), TAG.as_slice()].concat()); + + Ok(()) + } + + /// Tests that encrypted message can be decrypted back. + #[test] + fn test_decrypt_encrypted() -> Result<()> { + let init_vec = INIT_VEC.as_slice().try_into()?; + + let seal_payload = Payload { + msg: PLAINTEXT.as_bytes(), + aad: AAD.as_slice(), + }; + let sealed = encrypt(init_vec, seal_payload, &SYMKEY)?; + + let unseal_payload = Payload { + msg: &sealed, + aad: AAD.as_slice(), + }; + let unsealed = decrypt(init_vec, unseal_payload, &SYMKEY)?; + + assert_eq!(PLAINTEXT.to_string(), String::from_utf8(unsealed)?); + + Ok(()) + } + + /// Tests that plain text can be WCv2 serialized and deserialized back. + #[test] + fn test_encrypt_encode_decode_decrypt() -> Result<()> { + let encoded = encrypt_and_encode(EnvelopeType::Type0, PLAINTEXT, &SYMKEY)?; + let decoded = decode_and_decrypt_type0(&encoded, &SYMKEY)?; + assert_eq!(decoded, PLAINTEXT); + + Ok(()) + } +} diff --git a/sign_api/src/crypto/session.rs b/sign_api/src/crypto/session.rs new file mode 100644 index 0000000..12530ba --- /dev/null +++ b/sign_api/src/crypto/session.rs @@ -0,0 +1,79 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/session-proposal + +use { + hkdf::Hkdf, + rand::{rngs::OsRng, CryptoRng, RngCore}, + sha2::{Digest, Sha256}, + std::fmt::{Debug, Formatter}, + 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), +} + +#[derive(Clone)] +pub struct SessionKey { + sym_key: [u8; 32], + public_key: PublicKey, +} + +impl Debug for SessionKey { + /// Custom debug to hide the symmetrical key. + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionKey") + .field("sym_key", &"********") + .field("public_key", &self.public_key) + .finish() + } +} + +impl SessionKey { + /// Creates new session key from `osrng`. + /// + /// Helper for when `osrng` is good enough. + pub fn from_osrng(sender_public_key: &[u8; 32]) -> Result { + SessionKey::diffie_hellman(OsRng, sender_public_key) + } + + /// Performs Diffie-Hellman symmetric key derivation. + pub fn diffie_hellman(csprng: T, sender_public_key: &[u8; 32]) -> Result + where + T: RngCore + CryptoRng, + { + let single_use_private_key = EphemeralSecret::random_from_rng(csprng); + let public_key = PublicKey::from(&single_use_private_key); + + let ikm = single_use_private_key.diffie_hellman(&PublicKey::from(*sender_public_key)); + + let mut session_sym_key = Self { + sym_key: [0u8; 32], + public_key, + }; + let hk = Hkdf::::new(None, ikm.as_bytes()); + hk.expand(&[], &mut session_sym_key.sym_key) + .map_err(|e| 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/sign_api/src/lib.rs b/sign_api/src/lib.rs new file mode 100644 index 0000000..df91bc0 --- /dev/null +++ b/sign_api/src/lib.rs @@ -0,0 +1,3 @@ +pub mod crypto; +pub mod pairing_uri; +pub mod rpc; diff --git a/sign_api/src/pairing_uri.rs b/sign_api/src/pairing_uri.rs new file mode 100644 index 0000000..6a7aad7 --- /dev/null +++ b/sign_api/src/pairing_uri.rs @@ -0,0 +1,157 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/core/pairing/pairing-uri + +use { + regex::Regex, + std::{ + fmt::{Debug, Formatter}, + str::FromStr, + }, + url::Url, +}; + +#[derive(Debug, Clone, thiserror::Error, PartialEq)] +pub enum ParseError { + #[error("Expecting protocol \"wc\" but \"{protocol}\" is found.")] + UnexpectedProtocol { protocol: String }, + #[error(transparent)] + Url(#[from] url::ParseError), + #[error("Failed to parse topic and version")] + InvalidTopicAndVersion, + #[error("Topic not found")] + TopicNotFound, + #[error("Version not found")] + VersionNotFound, + #[error("Relay protocol not found")] + RelayProtocolNotFound, + #[error("Key not found")] + KeyNotFound, + #[error("Failed to parse key: {0:?}")] + InvalidKey(#[from] hex::FromHexError), + #[error("Unexpected parameter, key: {0:?}, value: {1:?}")] + UnexpectedParameter(String, String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Params { + pub relay_protocol: String, + pub sym_key: Vec, + pub relay_data: Option, +} + +/// https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1328.md +#[derive(Clone, Eq, PartialEq)] +pub struct Pairing { + pub topic: String, + pub version: String, + pub params: Params, +} + +impl Pairing { + fn parse_topic_and_version(path: &str) -> Result<(String, String), ParseError> { + let caps = Regex::new(r"^(?P[[:word:]-]+)@(?P\d+)$") + .expect("invalid 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)) + } + + fn parse_params(url: &Url) -> Result { + let queries = url.query_pairs(); + + let mut relay_protocol: Option = None; + let mut sym_key: Option = None; + let mut relay_data: Option = None; + for (k, v) in queries { + match k.as_ref() { + "relay-protocol" => relay_protocol = Some((*v).to_owned()), + "symKey" => sym_key = Some((*v).to_owned()), + "relay-data" => relay_data = Some((*v).to_owned()), + _ => { + return Result::Err(ParseError::UnexpectedParameter( + (*k).to_owned(), + (*v).to_owned(), + )) + } + } + } + + Ok(Params { + relay_protocol: relay_protocol.ok_or(ParseError::RelayProtocolNotFound)?, + sym_key: hex::decode(sym_key.ok_or(ParseError::KeyNotFound)?)?, + relay_data, + }) + } +} + +impl Debug for Pairing { + /// Debug with key masked. + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WalletConnectUrl") + .field("topic", &self.topic) + .field("version", &self.version) + .field("relay-protocol", &self.params.relay_protocol) + .field("key", &"***") + .field( + "relay-data", + &self.params.relay_data.as_deref().unwrap_or(""), + ) + .finish() + } +} + +impl FromStr for Pairing { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + let url = Url::from_str(s)?; + + if url.scheme() != "wc" { + return Result::Err(ParseError::UnexpectedProtocol { + protocol: url.scheme().to_owned(), + }); + } + + let (topic, version) = Self::parse_topic_and_version(url.path())?; + Ok(Self { + topic, + version, + params: Self::parse_params(&url)?, + }) + } +} + +#[cfg(test)] +mod tests { + use hex_literal::hex; + + use super::*; + + #[test] + fn parse_uri() { + let uri = "wc:c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168@2?relay-protocol=waku&symKey=7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b"; + + let actual = Pairing { + topic: "c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168".to_owned(), + version: "2".to_owned(), + params: Params { + relay_protocol: "waku".to_owned(), + sym_key: hex!("7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b") + .into(), + relay_data: None, + }, + }; + let expected = Pairing::from_str(uri).unwrap(); + + assert_eq!(actual, expected); + } +} diff --git a/sign_api/src/rpc.rs b/sign_api/src/rpc.rs new file mode 100644 index 0000000..dff40ab --- /dev/null +++ b/sign_api/src/rpc.rs @@ -0,0 +1,142 @@ +//! The crate exports common types used when interacting with messages between +//! clients. This also includes communication over HTTP between relays. + +mod params; + +use { + chrono::Utc, + serde::{Deserialize, Serialize}, + std::{fmt::Debug, sync::Arc}, +}; + +pub use params::*; + +/// Version of the WalletConnect protocol that we're implementing. +pub const JSON_RPC_VERSION_STR: &str = "2.0"; + +pub static JSON_RPC_VERSION: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| Arc::from(JSON_RPC_VERSION_STR)); + +/// Errors covering payload validation problems. +#[derive(Debug, thiserror::Error)] +pub enum ValidationError { + #[error("Invalid request ID")] + RequestId, + + #[error("Invalid JSON RPC version")] + JsonRpcVersion, +} + +/// Errors caught while processing the Sign API request/response. These should +/// be specific enough for the clients to make sense of the problem. +#[derive(Debug, thiserror::Error)] +pub enum GenericError { + /// Request parameters validation failed. + #[error("Request validation error: {0}")] + Validation(#[from] ValidationError), + + /// Request/response serialization error. + #[error("Serialization failed: {0}")] + Serialization(#[from] serde_json::Error), +} + +/// Enum representing a JSON RPC payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Payload { + Request(Request), + Response(Response), +} + +impl From for Payload { + fn from(value: Request) -> Self { + Payload::Request(value) + } +} + +impl From for Payload { + fn from(value: Response) -> Self { + Payload::Response(value) + } +} + +impl Payload { + pub fn validate(&self) -> Result<(), ValidationError> { + match self { + Self::Request(request) => request.validate(), + Self::Response(response) => response.validate(), + } + } + + pub fn irn_tag_in_range(tag: u32) -> bool { + (1100..=1115).contains(&tag) + } +} + +/// Data structure representing a JSON RPC request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Request { + /// ID this message corresponds to. + pub id: u64, + + /// The JSON RPC version. + pub jsonrpc: Arc, + + /// The parameters required to fulfill this request. + #[serde(flatten)] + pub params: RequestParams, +} + +impl Request { + /// Create a new instance. + pub fn new(params: RequestParams) -> Self { + Self { + id: Utc::now().timestamp_micros() as u64, + jsonrpc: JSON_RPC_VERSION_STR.into(), + params, + } + } + + /// Validates the request payload. + pub fn validate(&self) -> Result<(), ValidationError> { + if self.jsonrpc.as_ref() != JSON_RPC_VERSION_STR { + return Err(ValidationError::JsonRpcVersion); + } + + Ok(()) + } +} + +/// Data structure representing JSON RPC response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Response { + /// ID this message corresponds to. + pub id: u64, + + /// RPC version. + pub jsonrpc: Arc, + + /// The parameters required to fulfill this response. + #[serde(flatten)] + pub params: ResponseParams, +} + +impl Response { + /// Create a new instance. + pub fn new(id: u64, params: ResponseParams) -> Self { + Self { + id, + jsonrpc: JSON_RPC_VERSION.clone(), + params, + } + } + + /// Validates the parameters. + pub fn validate(&self) -> Result<(), ValidationError> { + if self.jsonrpc.as_ref() != JSON_RPC_VERSION_STR { + return Err(ValidationError::JsonRpcVersion); + } + + Ok(()) + } +} diff --git a/sign_api/src/rpc/params.rs b/sign_api/src/rpc/params.rs new file mode 100644 index 0000000..d857c17 --- /dev/null +++ b/sign_api/src/rpc/params.rs @@ -0,0 +1,263 @@ +pub(super) mod session_delete; +pub(super) mod session_event; +pub(super) mod session_extend; +pub(super) mod session_ping; +pub(super) mod session_propose; +pub(super) mod session_request; +pub(super) mod session_settle; +pub(super) mod session_update; +pub(super) mod shared_types; + +pub use { + session_delete::*, session_event::*, session_extend::*, session_ping::*, session_propose::*, + session_request::*, session_settle::*, session_update::*, shared_types::*, +}; + +use { + paste::paste, + serde::{Deserialize, Serialize}, + serde_json::Value, + std::result::Result, +}; + +/// 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(_) => session_propose::[], + [<$param_type>]::SessionSettle(_) => session_settle::[], + [<$param_type>]::SessionUpdate(_) => session_update::[], + [<$param_type>]::SessionExtend(_) => session_extend::[], + [<$param_type>]::SessionRequest(_) => session_request::[], + [<$param_type>]::SessionEvent(_) => session_event::[], + [<$param_type>]::SessionDelete(_) => session_delete::[], + [<$param_type>]::SessionPing(_) => session_ping::[], + } + } + } + } + } +} + +// Convenience macro to de-duplicate implementation for different parameter sets. +macro_rules! impl_relay_protocol_helpers { + ($param_type:ty) => { + paste! { + impl RelayProtocolHelpers for $param_type { + type Params = Self; + + fn irn_try_from_tag(value: Value, tag: u32) -> Result { + if tag == session_propose::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionPropose(serde_json::from_value(value)?)) + } else if tag == session_settle::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionSettle(serde_json::from_value(value)?)) + } else if tag == session_update::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionUpdate(serde_json::from_value(value)?)) + } else if tag == session_extend::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionExtend(serde_json::from_value(value)?)) + } else if tag == session_request::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionRequest(serde_json::from_value(value)?)) + } else if tag == session_event::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionEvent(serde_json::from_value(value)?)) + } else if tag == session_delete::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionDelete(serde_json::from_value(value)?)) + } else if tag == session_ping::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionPing(serde_json::from_value(value)?)) + } else { + 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)] +pub(crate) mod tests { + use super::*; + + use anyhow::Result; + use serde::de::DeserializeOwned; + use serde_json; + + /// 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_delete.rs b/sign_api/src/rpc/params/session_delete.rs new file mode 100644 index 0000000..ff8468c --- /dev/null +++ b/sign_api/src/rpc/params/session_delete.rs @@ -0,0 +1,45 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessiondelete + +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1112, + ttl: 86400, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1113, + ttl: 86400, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionDeleteRequest { + pub code: i64, + pub message: String, +} + +#[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/sign_api/src/rpc/params/session_event.rs b/sign_api/src/rpc/params/session_event.rs new file mode 100644 index 0000000..6db4e02 --- /dev/null +++ b/sign_api/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, 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, 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/sign_api/src/rpc/params/session_extend.rs b/sign_api/src/rpc/params/session_extend.rs new file mode 100644 index 0000000..702f964 --- /dev/null +++ b/sign_api/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, 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/sign_api/src/rpc/params/session_ping.rs b/sign_api/src/rpc/params/session_ping.rs new file mode 100644 index 0000000..9d9c58d --- /dev/null +++ b/sign_api/src/rpc/params/session_ping.rs @@ -0,0 +1,37 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionping + +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1114, + ttl: 30, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1115, + ttl: 30, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionPingRequest {} + +#[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/sign_api/src/rpc/params/session_propose.rs b/sign_api/src/rpc/params/session_propose.rs new file mode 100644 index 0000000..090fa9c --- /dev/null +++ b/sign_api/src/rpc/params/session_propose.rs @@ -0,0 +1,95 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionpropose + +use { + super::{IrnMetadata, Metadata, ProposeNamespaces, 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::*; + + use super::super::tests::param_serde_test; + use anyhow::Result; + + #[test] + fn test_serde_session_propose_request() -> Result<()> { + // 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/rpc/params/session_request.rs b/sign_api/src/rpc/params/session_request.rs new file mode 100644 index 0000000..8fb7853 --- /dev/null +++ b/sign_api/src/rpc/params/session_request.rs @@ -0,0 +1,72 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionrequest + +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1108, + ttl: 300, + prompt: true, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1109, + ttl: 300, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Request { + method: String, + /// Opaque blockchain RPC parameters. + /// + /// Parsing is deferred to a higher level, blockchain RPC aware code. + params: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + expiry: Option, +} + +#[derive(Debug, Serialize, PartialEq, Eq, 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/sign_api/src/rpc/params/session_settle.rs b/sign_api/src/rpc/params/session_settle.rs new file mode 100644 index 0000000..783fea1 --- /dev/null +++ b/sign_api/src/rpc/params/session_settle.rs @@ -0,0 +1,90 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionsettle + +use { + super::{IrnMetadata, Metadata, Relay, SettleNamespaces}, + 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, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Controller { + pub public_key: String, + pub metadata: Metadata, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct 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::*; + + use super::super::tests::param_serde_test; + use 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/src/rpc/params/session_update.rs b/sign_api/src/rpc/params/session_update.rs new file mode 100644 index 0000000..5a7c32d --- /dev/null +++ b/sign_api/src/rpc/params/session_update.rs @@ -0,0 +1,63 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionupdate + +use serde::{Deserialize, Serialize}; + +use super::{IrnMetadata, SettleNamespaces}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1104, + ttl: 86400, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1105, + ttl: 86400, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionUpdateRequest { + pub namespaces: 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) + } +} diff --git a/sign_api/src/rpc/params/shared_types.rs b/sign_api/src/rpc/params/shared_types.rs new file mode 100644 index 0000000..0d2b63e --- /dev/null +++ b/sign_api/src/rpc/params/shared_types.rs @@ -0,0 +1,27 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/data-structures + +mod propose_namespaces; +mod settle_namespaces; + +use serde::{Deserialize, Serialize}; +pub use { + propose_namespaces::{ProposeNamespace, ProposeNamespaceError, ProposeNamespaces}, + settle_namespaces::{SettleNamespace, SettleNamespaces}, +}; + +#[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/shared_types/propose_namespaces.rs b/sign_api/src/rpc/params/shared_types/propose_namespaces.rs new file mode 100644 index 0000000..209068e --- /dev/null +++ b/sign_api/src/rpc/params/shared_types/propose_namespaces.rs @@ -0,0 +1,579 @@ +use { + regex::Regex, + serde::{Deserialize, Serialize}, + 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 extensions are not supported")] + UnsupportedExtensions, + #[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), +} + +impl ProposeNamespaceError { + pub fn error_code(&self) -> i32 { + match self { + Self::UnsupportedChains(..) + | Self::UnsupportedChainsEmpty + | Self::UnsupportedChainsCaip2(..) + | Self::UnsupportedChainsNamespace(..) => 5100, + Self::UnsupportedEvents(..) + | Self::UnsupportedExtensions + | Self::UnsupportedMethods(..) + | Self::UnsupportedNamespace(..) + | Self::UnsupportedNamespaceKey(..) => 5104, + } + } +} + +/// 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.iter() { + 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(()) + } +} + +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub extensions: Option>, +} + +impl ProposeNamespace { + /// Ensures that application is compatible with the requester requirements. + /// + /// Implementation must support at least all the elements in `required`. + pub fn supported(&self, required: &ProposeNamespace) -> Result<(), ProposeNamespaceError> { + let join_error_elements = + |required: &BTreeSet, ours: &BTreeSet| -> String { + required + .difference(ours) + .map(|s| s.as_str()) + .collect::>() + .join(",") + }; + + if !self.chains.is_superset(&required.chains) { + return Err(ProposeNamespaceError::UnsupportedChains( + join_error_elements(&required.chains, &self.chains), + )); + } + + if !self.methods.is_superset(&required.methods) { + return Err(ProposeNamespaceError::UnsupportedMethods( + join_error_elements(&required.methods, &self.methods), + )); + } + + if !self.events.is_superset(&required.events) { + return Err(ProposeNamespaceError::UnsupportedEvents( + join_error_elements(&required.events, &self.events), + )); + } + + // TODO: extension comparison probably should follow the same + // validation process as above for non optional namespaces. + match (&self.extensions, &required.extensions) { + (Some(this), Some(other)) => { + if !other.iter().all(|item| this.contains(item)) { + return Err(ProposeNamespaceError::UnsupportedExtensions); + } + } + (Some(_), None) => { + return Err(ProposeNamespaceError::UnsupportedExtensions); + } + (None, Some(_)) | (None, None) => {} + } + + Ok(()) + } + + pub fn chains_caip2_validate( + &self, + namespace: &str, + reference: Option<&str>, + ) -> Result<(), ProposeNamespaceError> { + println!("{namespace}, {reference:?}"); + + // 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(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ======================================================================================================== + // 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()), + extensions: None, + } + } + + /// 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(()) + } +} diff --git a/sign_api/src/rpc/params/shared_types/settle_namespaces.rs b/sign_api/src/rpc/params/shared_types/settle_namespaces.rs new file mode 100644 index 0000000..9f8f38b --- /dev/null +++ b/sign_api/src/rpc/params/shared_types/settle_namespaces.rs @@ -0,0 +1,33 @@ +use { + serde::{Deserialize, Serialize}, + std::{ + collections::{BTreeMap, BTreeSet}, + ops::Deref, + }, +}; + +/// TODO: some validation from `ProposeNamespaces` should be re-used. +/// TODO: caip-10 validation. +/// TODO: named errors like in `ProposeNamespaces`. +#[derive(Debug, Serialize, PartialEq, Eq, 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, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettleNamespace { + pub accounts: BTreeSet, + pub methods: BTreeSet, + pub events: BTreeSet, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub extensions: Option>, +} From 9b78bb85df4e3691bad8de65183b666f224a1273 Mon Sep 17 00:00:00 2001 From: Silvestrs Timofejevs Date: Tue, 24 Oct 2023 16:57:50 +0100 Subject: [PATCH 3/4] feat: create a simple Sign API example on top of the WS client This client implements pairing and session flows: https://specs.walletconnect.com/2.0/specs/clients/core/pairing https://specs.walletconnect.com/2.0/specs/clients/sign/session-proposal Supported actions: - pairing - session establishment - session delete - ping This example could be expanded to handle multiple sessions. Caution: The purpose of this example is demonstration of core Sign API functionality. However, it shouldn't be used in production, as might exhibit some race conditions such as with session deletion, etc... --- sign_api/Cargo.toml | 9 + sign_api/examples/session.rs | 516 +++++++++++++++++++++++++++++++++++ 2 files changed, 525 insertions(+) create mode 100644 sign_api/examples/session.rs diff --git a/sign_api/Cargo.toml b/sign_api/Cargo.toml index 86739e0..3688018 100644 --- a/sign_api/Cargo.toml +++ b/sign_api/Cargo.toml @@ -30,3 +30,12 @@ url = "2.4" serde_json = { version = "1.0", features = ["preserve_order"] } anyhow = "1" +clap = { version = "4.4", features = ["derive"] } +chrono = "0.4" +dashmap = "5.5" +relay_client = { path = "../relay_client" } +relay_rpc = { path = "../relay_rpc" } +tokio = { version = "1.22", features = ["full"] } + +[[example]] +name = "session" diff --git a/sign_api/examples/session.rs b/sign_api/examples/session.rs new file mode 100644 index 0000000..236cbda --- /dev/null +++ b/sign_api/examples/session.rs @@ -0,0 +1,516 @@ +//! Simple WalletConnect v2 client to demonstrate Sign API functionality. +//! +//! Current capabilities: +//! - Session pairing +//! - New session establishment +//! +//! Please note that this client is for demonstration purposes only, and hence +//! it doesn't handle many things that a complete client should handle: +//! - TTL control +//! - Out-of-order requests/responses (potentially) +//! - proper session and pairing control +//! - ... +//! +//! This example may exhibit a number of issues: +//! - Race conditions when terminating the pairing +//! - However, in practice, this shouldn't happen with a single session. +//! In theory support for N sessions has been provisioned, however, +//! not yet utilized. +//! - Pairing termination logic is not sound, we should drain and either reject +//! or honour any "residue" messages before unsubscribing from the pairing. +//! - ... +//! - Pairing is not deleted in the end, but it should be. + +use { + anyhow::Result, + chrono::Utc, + clap::Parser, + dashmap::DashMap, + relay_client::{ + error::Error, + websocket::{Client, CloseFrame, ConnectionHandler, PublishedMessage}, + ConnectionOptions, + }, + relay_rpc::{ + auth::{ed25519_dalek::Keypair, rand, AuthToken}, + domain::{SubscriptionId, Topic}, + }, + sign_api::{ + crypto::{ + payload::{decode_and_decrypt_type0, encrypt_and_encode, EnvelopeType}, + session::SessionKey, + }, + pairing_uri::Pairing as PairingData, + rpc::*, + }, + std::{collections::BTreeMap, str::FromStr, sync::Arc}, + tokio::{ + select, + sync::mpsc::{channel, unbounded_channel, Sender, UnboundedSender}, + time::Duration, + }, +}; + +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 = "3cbaa32f8fbf3cdcc87d27ca1fa68069")] + 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: Error) { + println!("\n[{}] inbound error: {error}", self.name); + } + + fn outbound_error(&mut self, error: Error) { + println!("\n[{}] outbound error: {error}", self.name); + } +} + +fn create_conn_opts(address: &str, project_id: &str) -> ConnectionOptions { + let key = Keypair::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: &Context, + 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 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: &Context, request: Request, topic: Topic) -> Result<()> { + let mut session_delete_cleanup_required: Option = None; + let response = match request.params { + RequestParams::SessionPropose(proposal) => { + process_proposal_request(context, proposal).await? + } + RequestParams::SessionDelete(params) => { + session_delete_cleanup_required = Some(topic.clone()); + process_session_delete_request(params) + } + RequestParams::SessionPing(_) => ResponseParamsSuccess::SessionPing(true), + _ => todo!(), + }; + + 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.params { + ResponseParams::Success(value) => { + let params = serde_json::from_value::(value)?; + match params { + ResponseParamsSuccess::SessionSettle(b) + | ResponseParamsSuccess::SessionDelete(b) + | ResponseParamsSuccess::SessionPing(b) => { + if !b { + anyhow::bail!("Unsuccessful response={params:?}"); + } + + Ok(()) + } + _ => todo!(), + } + } + ResponseParams::Err(value) => { + let params = serde_json::from_value::(value)?; + anyhow::bail!("DApp send and error response: {params:?}"); + } + } +} + +async fn process_inbound_message(context: &Context, message: PublishedMessage) -> Result<()> { + let plain = 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: DashMap, +} + +impl Context { + fn new(client: Client, pairing: Pairing) -> Arc { + Arc::new(Self { + client, + pairing, + sessions: DashMap::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 request = Request::new(params); + 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: u64, + params: ResponseParamsSuccess, + ) -> Result<()> { + let irn_metadata = params.irn_metadata(); + let response = Response::new(id, params.try_into()?); + 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), + irn_metadata.tag, + Duration::from_secs(irn_metadata.ttl), + irn_metadata.prompt, + ) + .await?; + + 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(&self, topic: Topic) -> Result<()> { + let (topic, session) = self + .sessions + .remove(&topic) + .ok_or_else(|| anyhow::anyhow!("Attempt to remove non-existing session"))?; + + self.client + .unsubscribe(topic, session.subscription_id) + .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(), + self.pairing.subscription_id.clone(), + ) + .await?; + + self.pairing.terminator.send(()).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 489e2e3a33bbde71fdd0aebfedfac28f250acf4e Mon Sep 17 00:00:00 2001 From: Silvestrs Timofejevs Date: Wed, 20 Dec 2023 06:50:34 +0000 Subject: [PATCH 4/4] chore: add Sign API README.md --- sign_api/README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 sign_api/README.md diff --git a/sign_api/README.md b/sign_api/README.md new file mode 100644 index 0000000..c6525a4 --- /dev/null +++ b/sign_api/README.md @@ -0,0 +1,23 @@ +# Rust Sign API SDK + +This crate implements Sign API, described in: +https://specs.walletconnect.com/2.0/specs/clients/sign/ + +There is a simple Sign API client example built on top of the websocket client, which can be run as follows: +- In a browser, open: https://react-app.walletconnect.com/ +- Click on "Goerli" and then "connect" +- In a new pop-up, click "New Pairing" +- Copy the Pairing URI +- In the terminal, cd _path/to/WalletConnectRust/sign_api_ +- .../sign_api$ cargo run --example session "_copied URI_" +- DApp should now display the session window +- Click disconnect to terminate session and pairing + +__Warning: this Rust Sign API SDK is community-maintained and may be lacking features and stability or security fixes that other versions of the Sign API SDK receive. We strongly recommend using the JavaScript or other Sign API SDKs instead.__ + +## Disclaimer + +Please note that this crate is still under development, and thus: +- Is incomplete +- Might lack testing in places +- Being developed from a wallet perspective, and thus some DApp specific SDK details might have been overlooked