diff --git a/Cargo.toml b/Cargo.toml index 8b29593..a70043c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,18 +9,21 @@ license = "Apache-2.0" [workspace] members = [ "relay_client", - "relay_rpc" + "relay_rpc", + "sign_api" ] [features] default = ["full"] -full = ["client", "rpc"] +full = ["client", "rpc", "sign_api"] client = ["dep:relay_client"] rpc = ["dep:relay_rpc"] +sign_api = ["dep:sign_api"] [dependencies] relay_client = { path = "./relay_client", optional = true } relay_rpc = { path = "./relay_rpc", optional = true } +sign_api = { path = "./sign_api", optional = true } [dev-dependencies] anyhow = "1" diff --git a/sign_api/Cargo.toml b/sign_api/Cargo.toml new file mode 100644 index 0000000..95b9004 --- /dev/null +++ b/sign_api/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "sign_api" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1" +base58 = "0.2" +base64 = "0.21" +base64-url = "2.0" +chacha20poly1305 = "0.10" +chrono = "0.4" +hex = "0.4" +hex-literal = "0.4" +hkdf = "0.12" +lazy_static = "1.4" +once_cell = "1.16" +paste = "1.0" +rand = "0.8" +regex = "1.10" +sha2 = "0.10" +serde = { version = "1.0", features = ["derive", "rc"] } +serde_json = "1.0" +thiserror = "1.0" +x25519-dalek = { version = "2.0", features = ["static_secrets"] } +url = "2.4" diff --git a/sign_api/src/crypto.rs b/sign_api/src/crypto.rs new file mode 100644 index 0000000..45b480b --- /dev/null +++ b/sign_api/src/crypto.rs @@ -0,0 +1,2 @@ +pub mod payload; +pub mod session; diff --git a/sign_api/src/crypto/payload.rs b/sign_api/src/crypto/payload.rs new file mode 100644 index 0000000..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..89b9260 --- /dev/null +++ b/sign_api/src/pairing_uri.rs @@ -0,0 +1,164 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/core/pairing/pairing-uri + +use { + anyhow::Result, + lazy_static::lazy_static, + regex::Regex, + std::{ + fmt::{Debug, Formatter}, + str::FromStr, + }, + url::Url, +}; + +lazy_static! { + static ref TOPIC_AND_VERSION_RE: Regex = + Regex::new(r"^(?P[[[:word:]]-]+)@(?P\d+)$") + .expect("invalid TOPIC_AND_VERSION_RE in wallet_connect"); +} + +#[derive(Debug, Clone, thiserror::Error, PartialEq)] +pub enum ParseError { + #[error("Expecting protocol \"wc\" but \"{protocol}\" is found.")] + UnexpectedProtocol { protocol: String }, + #[error(transparent)] + Url(#[from] url::ParseError), + #[error("Failed to parse topic and version")] + InvalidTopicAndVersion, + #[error("Topic not found")] + TopicNotFound, + #[error("Version not found")] + VersionNotFound, + #[error("Relay protocol not found")] + RelayProtocolNotFound, + #[error("Key not found")] + KeyNotFound, + #[error("Failed to parse key: {0:?}")] + InvalidKey(#[from] hex::FromHexError), + #[error("Unexpected parameter, key: {0:?}, value: {1:?}")] + UnexpectedParameter(String, String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Params { + pub relay_protocol: String, + pub sym_key: Vec, + pub relay_data: Option, +} + +/// https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1328.md +#[derive(Clone, Eq, PartialEq)] +pub struct Pairing { + pub topic: String, + pub version: String, + pub params: Params, +} + +impl Pairing { + fn parse_topic_and_version(path: &str) -> Result<(String, String), ParseError> { + let caps = TOPIC_AND_VERSION_RE + .captures(path) + .ok_or(ParseError::InvalidTopicAndVersion)?; + let topic = caps + .name("topic") + .ok_or(ParseError::TopicNotFound)? + .as_str() + .to_owned(); + let version = caps + .name("version") + .ok_or(ParseError::VersionNotFound)? + .as_str() + .to_owned(); + Ok((topic, version)) + } + + fn parse_params(url: &Url) -> Result { + let queries = url.query_pairs(); + + let mut relay_protocol: Option = None; + let mut sym_key: Option = None; + let mut relay_data: Option = None; + for (k, v) in queries { + match k.as_ref() { + "relay-protocol" => relay_protocol = Some((*v).to_owned()), + "symKey" => sym_key = Some((*v).to_owned()), + "relay-data" => relay_data = Some((*v).to_owned()), + _ => { + return Result::Err(ParseError::UnexpectedParameter( + (*k).to_owned(), + (*v).to_owned(), + )) + } + } + } + + Ok(Params { + relay_protocol: relay_protocol.ok_or(ParseError::RelayProtocolNotFound)?, + sym_key: hex::decode(sym_key.ok_or(ParseError::KeyNotFound)?)?, + relay_data, + }) + } +} + +impl Debug for Pairing { + /// Debug with key masked. + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WalletConnectUrl") + .field("topic", &self.topic) + .field("version", &self.version) + .field("relay-protocol", &self.params.relay_protocol) + .field("key", &"***") + .field( + "relay-data", + &self.params.relay_data.as_deref().unwrap_or(""), + ) + .finish() + } +} + +impl FromStr for Pairing { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + let url = Url::from_str(s)?; + + if url.scheme() != "wc" { + return Result::Err(ParseError::UnexpectedProtocol { + protocol: url.scheme().to_owned(), + }); + } + + let (topic, version) = Self::parse_topic_and_version(url.path())?; + Ok(Self { + topic, + version, + params: Self::parse_params(&url)?, + }) + } +} + +#[cfg(test)] +mod tests { + use hex_literal::hex; + + use super::*; + + #[test] + fn parse_uri() { + let uri = "wc:c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168@2?relay-protocol=waku&symKey=7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b"; + + let actual = Pairing { + topic: "c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168".to_owned(), + version: "2".to_owned(), + params: Params { + relay_protocol: "waku".to_owned(), + sym_key: hex!("7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b") + .into(), + relay_data: None, + }, + }; + let expected = Pairing::from_str(uri).unwrap(); + + assert_eq!(actual, expected); + } +} diff --git a/sign_api/src/rpc.rs b/sign_api/src/rpc.rs new file mode 100644 index 0000000..0456cf1 --- /dev/null +++ b/sign_api/src/rpc.rs @@ -0,0 +1,151 @@ +//! The crate exports common types used when interacting with messages between +//! clients. This also includes communication over HTTP between relays. + +mod params; + +use { + anyhow::Result, + chrono::Utc, + serde::{Deserialize, Serialize}, + std::{fmt::Debug, sync::Arc}, +}; + +pub use params::*; + +/// Version of the WalletConnect protocol that we're implementing. +pub const JSON_RPC_VERSION_STR: &str = "2.0"; + +pub static JSON_RPC_VERSION: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| Arc::from(JSON_RPC_VERSION_STR)); + +/// Errors covering payload validation problems. +#[derive(Debug, 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 { + /// Returns the message ID contained within the payload. + pub fn id(&self) -> u64 { + match self { + Self::Request(req) => req.id, + Self::Response(res) => res.id, + } + } + + pub fn validate(&self) -> Result<(), ValidationError> { + match self { + Self::Request(request) => request.validate(), + Self::Response(response) => response.validate(), + } + } + + pub fn irn_tag_in_range(tag: u32) -> bool { + (1100..=1115).contains(&tag) + } +} + +/// Data structure representing a JSON RPC request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Request { + /// ID this message corresponds to. + pub id: u64, + + /// The JSON RPC version. + pub jsonrpc: Arc, + + /// The parameters required to fulfill this request. + #[serde(flatten)] + pub params: RequestParams, +} + +impl Request { + /// Create a new instance. + pub fn new(params: RequestParams) -> Self { + Self { + id: Utc::now().timestamp_micros() as u64, + jsonrpc: JSON_RPC_VERSION_STR.into(), + params, + } + } + + /// Validates the request payload. + pub fn validate(&self) -> Result<(), ValidationError> { + if self.jsonrpc.as_ref() != JSON_RPC_VERSION_STR { + return Err(ValidationError::JsonRpcVersion); + } + + Ok(()) + } +} + +/// Data structure representing JSON RPC response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Response { + /// ID this message corresponds to. + pub id: u64, + + /// RPC version. + pub jsonrpc: Arc, + + /// The parameters required to fulfill this response. + #[serde(flatten)] + pub params: ResponseParams, +} + +impl Response { + /// Create a new instance. + pub fn new(id: u64, params: ResponseParams) -> Self { + Self { + id, + jsonrpc: JSON_RPC_VERSION.clone(), + params, + } + } + + /// Validates the parameters. + pub fn validate(&self) -> Result<(), ValidationError> { + if self.jsonrpc.as_ref() != JSON_RPC_VERSION_STR { + return Err(ValidationError::JsonRpcVersion); + } + + Ok(()) + } +} diff --git a/sign_api/src/rpc/params.rs b/sign_api/src/rpc/params.rs new file mode 100644 index 0000000..2757a6c --- /dev/null +++ b/sign_api/src/rpc/params.rs @@ -0,0 +1,230 @@ +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)?)) + } +} diff --git a/sign_api/src/rpc/params/session_delete.rs b/sign_api/src/rpc/params/session_delete.rs new file mode 100644 index 0000000..71d1c84 --- /dev/null +++ b/sign_api/src/rpc/params/session_delete.rs @@ -0,0 +1,25 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessiondelete + +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1112, + ttl: 86400, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1113, + ttl: 86400, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionDeleteRequest { + pub code: i64, + pub message: String, +} diff --git a/sign_api/src/rpc/params/session_event.rs b/sign_api/src/rpc/params/session_event.rs new file mode 100644 index 0000000..b950e9e --- /dev/null +++ b/sign_api/src/rpc/params/session_event.rs @@ -0,0 +1,32 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionevent + +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1110, + ttl: 300, + prompt: true, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1111, + ttl: 300, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Event { + name: String, + data: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionEventRequest { + event: Event, + chain_id: String, +} diff --git a/sign_api/src/rpc/params/session_extend.rs b/sign_api/src/rpc/params/session_extend.rs new file mode 100644 index 0000000..5d8f918 --- /dev/null +++ b/sign_api/src/rpc/params/session_extend.rs @@ -0,0 +1,24 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionextend + +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1106, + ttl: 86400, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1107, + ttl: 86400, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionExtendRequest { + pub expiry: u64, +} diff --git a/sign_api/src/rpc/params/session_ping.rs b/sign_api/src/rpc/params/session_ping.rs new file mode 100644 index 0000000..385e015 --- /dev/null +++ b/sign_api/src/rpc/params/session_ping.rs @@ -0,0 +1,22 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionping + +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1114, + ttl: 30, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1115, + ttl: 30, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionPingRequest {} diff --git a/sign_api/src/rpc/params/session_propose.rs b/sign_api/src/rpc/params/session_propose.rs new file mode 100644 index 0000000..f70c876 --- /dev/null +++ b/sign_api/src/rpc/params/session_propose.rs @@ -0,0 +1,40 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionpropose + +use serde::{Deserialize, Serialize}; + +use super::{IrnMetadata, Metadata, Namespaces, Relay}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1100, + ttl: 300, + prompt: true, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1101, + ttl: 300, + prompt: false, +}; + +#[derive(Debug, Serialize, Eq, PartialEq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Proposer { + pub public_key: String, + pub metadata: Metadata, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionProposeRequest { + pub relays: Vec, + pub proposer: Proposer, + pub required_namespaces: Namespaces, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionProposeResponse { + pub relay: Relay, + pub responder_public_key: String, +} diff --git a/sign_api/src/rpc/params/session_request.rs b/sign_api/src/rpc/params/session_request.rs new file mode 100644 index 0000000..d4f81e3 --- /dev/null +++ b/sign_api/src/rpc/params/session_request.rs @@ -0,0 +1,32 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionrequest + +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1108, + ttl: 300, + prompt: true, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1109, + ttl: 300, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Request { + method: String, + params: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionRequestRequest { + pub request: Request, + pub chain_id: String, +} diff --git a/sign_api/src/rpc/params/session_settle.rs b/sign_api/src/rpc/params/session_settle.rs new file mode 100644 index 0000000..3b67ce6 --- /dev/null +++ b/sign_api/src/rpc/params/session_settle.rs @@ -0,0 +1,57 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionsettle + +use serde::{Deserialize, Serialize}; + +use super::{IrnMetadata, Metadata, Relay}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1102, + ttl: 300, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1103, + ttl: 300, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Controller { + pub public_key: String, + pub metadata: Metadata, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettleNamespaces { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub eip155: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub cosmos: Option, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettleNamespace { + pub accounts: Vec, + pub methods: Vec, + pub events: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub extensions: Option>, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SessionSettleRequest { + pub relay: Relay, + pub controller: Controller, + pub namespaces: SettleNamespaces, + /// uSecs contrary to what documentation says (secs). + pub expiry: u64, +} diff --git a/sign_api/src/rpc/params/session_update.rs b/sign_api/src/rpc/params/session_update.rs new file mode 100644 index 0000000..d8f2546 --- /dev/null +++ b/sign_api/src/rpc/params/session_update.rs @@ -0,0 +1,24 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionupdate + +use serde::{Deserialize, Serialize}; + +use super::{IrnMetadata, Namespaces}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1104, + ttl: 86400, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1105, + ttl: 86400, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionUpdateRequest { + pub namespaces: Namespaces, +} diff --git a/sign_api/src/rpc/params/shared_types.rs b/sign_api/src/rpc/params/shared_types.rs new file mode 100644 index 0000000..f37b652 --- /dev/null +++ b/sign_api/src/rpc/params/shared_types.rs @@ -0,0 +1,174 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/data-structures + +use { + serde::{Deserialize, Serialize}, + std::collections::BTreeSet, +}; + +/// Errors covering namespace validation errors. +/// +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces +/// and some additional variants. +#[derive(Debug, thiserror::Error)] +pub enum NamespaceError { + #[error("Required chains are not supported: {0}")] + UnsupportedChains(String), + #[error("Chains must not be empty")] + UnsupportedChainsEmpty, + #[error("Chains must be CAIP-2 compliant")] + UnsupportedChainsCaip2, + #[error("Chains must be defined in matching namespace")] + UnsupportedChainsNamespace, + #[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")] + UnsupportedNamespaceKey, +} + +impl NamespaceError { + 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, + } + } +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Metadata { + pub description: String, + pub url: String, + pub icons: Vec, + pub name: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +pub struct Relay { + pub protocol: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub data: Option, +} + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces +#[derive(Debug, Serialize, Eq, PartialEq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Namespaces { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub eip155: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub cosmos: Option, +} + +impl Namespaces { + /// Ensures that application is compatible with the requester requirements. + /// + /// Implementation must support at least all the elements in `required`. + pub fn supported(&self, required: &Namespaces) -> Result<(), NamespaceError> { + if self.eip155.is_none() && self.cosmos.is_none() { + return Err(NamespaceError::UnsupportedNamespace( + "None supported".to_string(), + )); + } + + match (&required.eip155, &self.eip155) { + (Some(other), Some(this)) => { + return this.supported(other); + } + (Some(_), None) => { + return Err(NamespaceError::UnsupportedNamespace("eip155".to_string())); + } + (None, Some(_)) | (None, None) => {} + } + + match (&required.cosmos, &self.cosmos) { + (Some(other), Some(this)) => { + return this.supported(other); + } + (Some(_), None) => { + return Err(NamespaceError::UnsupportedNamespace("cosmos".to_string())); + } + (None, Some(_)) | (None, None) => {} + } + + Ok(()) + } +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Namespace { + pub chains: BTreeSet, + pub methods: BTreeSet, + pub events: BTreeSet, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub extensions: Option>, +} + +impl Namespace { + /// Ensures that application is compatible with the requester requirements. + /// + /// Implementation must support at least all the elements in `required`. + pub fn supported(&self, required: &Namespace) -> Result<(), NamespaceError> { + 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(NamespaceError::UnsupportedChains(join_error_elements( + &required.chains, + &self.chains, + ))); + } + + if !self.methods.is_superset(&required.methods) { + return Err(NamespaceError::UnsupportedMethods(join_error_elements( + &required.methods, + &self.methods, + ))); + } + + if !self.events.is_superset(&required.events) { + return Err(NamespaceError::UnsupportedEvents(join_error_elements( + &required.events, + &self.events, + ))); + } + + match (&self.extensions, &required.extensions) { + (Some(this), Some(other)) => { + if !other.iter().all(|item| this.contains(item)) { + return Err(NamespaceError::UnsupportedExtensions); + } + } + (Some(_), None) => { + return Err(NamespaceError::UnsupportedExtensions); + } + (None, Some(_)) | (None, None) => {} + } + + Ok(()) + } +}