Skip to main content

walletconnect_sdk/
pairing.rs

1/// Pairing
2///
3/// Implementation of walletconnect specs to pair with a dApp and fetch messages.
4///
5use std::time::Duration;
6use std::{str, thread};
7
8use alloy::hex;
9use alloy::primitives::Address;
10use serde::Serialize;
11use serde::de::DeserializeOwned;
12use serde_json::Value;
13
14use crate::cacao::Cacao;
15use crate::connection::Connection;
16use crate::error::{Error, Result};
17use crate::message::Message;
18use crate::types::{
19    EncryptedMessage, IrnTag, Namespace, Participant, Relay,
20    SessionAuthenticateResponse, SessionProposeResponse, SessionSettleParams,
21};
22use crate::utils::{
23    DAYS, UriParameters, derive_sym_key, random_bytes32, sha256, unix_timestamp,
24};
25use crate::wc_message::{WcData, WcMessage, WcMethod};
26
27#[derive(Debug, Clone, Copy)]
28pub enum Topic {
29    // We get this topic from the URI scanned from QR code
30    Initial,
31    // This is hash of the dapp's public key, used during handshake
32    Response,
33    // This is using derived symmetric key from both public keys
34    Derived,
35}
36
37#[derive(Debug, Clone, PartialEq)]
38pub struct Pairing {
39    private_key: [u8; 32],
40    params: UriParameters,
41    connection: Connection,
42    proposal_request: Option<WcMessage>,
43    authenticate_request: Option<WcMessage>,
44    approve_done: bool,
45}
46
47impl Pairing {
48    pub fn new(uri: &str, connection: Connection) -> crate::Result<Self> {
49        let params = UriParameters::try_from(uri.to_string())?;
50        Ok(Self {
51            // Generate a fresh private key for the pairing
52            private_key: random_bytes32(),
53            params,
54            connection,
55            proposal_request: None,
56            authenticate_request: None,
57            approve_done: false,
58        })
59    }
60
61    /// Initialise the pairing process
62    ///
63    /// 1. Subscribe to the relay with the topic in the URI
64    /// 2. Fetch messages from the relay sent by the dapp
65    /// 3. Decrypt the messages
66    /// 4. Check if the first message is a session_propose and the second is a session_authenticate
67    ///
68    /// To continue the process further, use the `approve` method
69    pub async fn init_pairing(&mut self) -> Result<WcMessage> {
70        self.subscribe(Topic::Initial).await?;
71
72        let messages = self.fetch_messages(Topic::Initial).await?;
73
74        if messages.is_empty() {
75            return Err(
76                "Please generate a fresh WalletConnect URI from the dApp"
77                    .into(),
78            );
79        }
80
81        if messages.len() == 1 {
82            // Sometimes dApps send us just the SessionPropose message
83            if messages[0].method() != Some(WcMethod::SessionPropose) {
84                return Err("Message is not SessionPropose".into());
85            }
86
87            self.proposal_request = Some(messages[0].clone());
88            Ok(messages[0].clone())
89        } else if messages.len() == 2 {
90            // Sometimes dApps send us both SessionPropose and SessionAuthenticate messages
91            let proposal_request = messages
92                .iter()
93                .find(|m| m.method() == Some(WcMethod::SessionPropose))
94                .ok_or("SessionPropose message not found")?;
95
96            let authenticate_request = messages
97                .iter()
98                .find(|m| m.method() == Some(WcMethod::SessionAuthenticate))
99                .ok_or("SessionAuthenticate message not found")?;
100
101            self.proposal_request = Some(proposal_request.clone());
102            self.authenticate_request = Some(authenticate_request.clone());
103
104            {
105                let proposal_request =
106                    proposal_request.data.as_session_propose().ok_or(
107                        crate::Error::InternalError2("not session propose"),
108                    )?;
109                let authenticate_request = authenticate_request
110                    .data
111                    .as_session_authenticate()
112                    .ok_or(crate::Error::InternalError2(
113                        "not session authenticate",
114                    ))?;
115                assert_eq!(
116                    proposal_request.proposer.public_key,
117                    authenticate_request.requester.public_key,
118                    "proposer and requester public keys are not equal - {messages:?}"
119                );
120            }
121            Ok(proposal_request.clone())
122        } else {
123            Err(format!(
124                "Expected 1 or 2 messages, got {}: {messages:?}",
125                messages.len()
126            )
127            .into())
128        }
129    }
130
131    /// Approve the pairing by using wc_sessionSettle
132    ///
133    /// 1. Create a SessionProposeResponse message on the SessionProposal message id, mention our public key in it.
134    /// 2. Create a SessionSettle message with the session properties encrypted using the derived symmetric key
135    /// 3. Also subscribe to the topic derived from the symmetric key to receive messages from the dappcxd
136    pub async fn approve_with_session_settle(
137        &mut self,
138        account_address: Address,
139    ) -> Result<Vec<WcMessage>> {
140        let proposal = self.get_proposal()?;
141        let response = proposal.create_response(
142            WcData::SessionProposeResponse(SessionProposeResponse {
143                relay: Relay {
144                    protocol: "irn".to_string(),
145                },
146                responder_public_key: self.public_key(),
147            }),
148            None,
149        );
150
151        self.send_message(
152            Topic::Initial,
153            &response.into_raw()?,
154            Some(0),
155            IrnTag::SessionProposeApproveResponse,
156            3600,
157        )
158        .await?;
159
160        self.subscribe(Topic::Derived).await?;
161
162        let proposal =
163            proposal.data.as_session_propose().ok_or("not proposal")?;
164
165        let session_settle =
166            self.new_message(WcData::SessionSettle(SessionSettleParams {
167                controller: self.participant(),
168                expiry: unix_timestamp()? + 10 * DAYS,
169                namespaces: proposal
170                    .required_namespaces
171                    .clone()
172                    .into_iter()
173                    .chain(proposal.optional_namespaces.clone())
174                    .map(|(name, n)| {
175                        (
176                            name,
177                            Namespace {
178                                accounts: Some(
179                                    n.chains
180                                        .iter()
181                                        .map(|c| {
182                                            format!("{c}:{account_address}")
183                                        })
184                                        .collect(),
185                                ),
186                                chains: n.chains,
187                                events: n.events,
188                                methods: vec![
189                                    "personal_sign".to_string(),
190                                    "eth_sendTransaction".to_string(),
191                                    "eth_signTypedData_v4".to_string(),
192                                ],
193                            },
194                        )
195                    })
196                    .collect(),
197                relay: Relay {
198                    protocol: "irn".to_string(),
199                },
200                session_properties: None,
201            }))?;
202
203        self.send_message(
204            Topic::Derived,
205            &session_settle,
206            Some(0),
207            IrnTag::SessionSettle,
208            3600,
209        )
210        .await?;
211
212        let mut excess_messages = vec![];
213        let mut success = false;
214
215        loop {
216            let mut messages = self.fetch_messages(Topic::Derived).await?;
217            let mut rm_idx = None;
218            for (i, msg) in messages.iter().enumerate() {
219                if msg.id == session_settle.id {
220                    let result = msg.data.as_result::<bool>();
221
222                    if let Some(result) = result {
223                        success = result;
224                    }
225
226                    rm_idx = Some(i);
227                }
228            }
229            if let Some(i) = rm_idx {
230                messages.remove(i);
231            }
232            excess_messages.extend(messages);
233            if rm_idx.is_some() {
234                break;
235            }
236        }
237
238        if success {
239            self.approve_done = true;
240            Ok(excess_messages)
241        } else {
242            Err(crate::Error::PairingNotApproved)
243        }
244    }
245
246    /// Approve the pairing by responding to wc_sessionAuthenticate
247    ///
248    /// 1. Create a SessionAuthenticateResponse message on the
249    ///    SessionAuthenticate message id. Encrypt the message using derived
250    ///    symetric key and mention our public key by using the type 1 envelope.
251    pub async fn approve_with_cacao(&self, cacao: Cacao) -> Result<()> {
252        if cacao.signature.is_none() {
253            return Err("Cacao signature is None".into());
254        }
255
256        cacao.verify()?;
257
258        let message =
259            self.authenticate_request.as_ref().unwrap().create_response(
260                WcData::SessionAuthenticateResponse(
261                    SessionAuthenticateResponse {
262                        cacaos: vec![cacao],
263                        responder: self.participant(),
264                    },
265                ),
266                None,
267            );
268
269        self.send_message(
270            Topic::Derived,
271            &message.into_raw()?,
272            Some(1),
273            IrnTag::SessionAuthenticateApproveResponse,
274            3600,
275        )
276        .await?;
277
278        Ok(())
279    }
280
281    pub async fn watch_messages(
282        &self,
283        topic: Topic,
284        dur: Option<Duration>,
285    ) -> Result<Vec<WcMessage>> {
286        loop {
287            let result = self.fetch_messages(topic).await?;
288            if !result.is_empty() {
289                return Ok(result);
290            }
291            thread::sleep(dur.unwrap_or(Duration::from_secs(1)));
292        }
293    }
294
295    fn new_message(&self, data: WcData) -> crate::Result<Message> {
296        if data.result()?.is_some() {
297            return Err("Cannot create new message with result".into());
298        }
299        Ok(Message {
300            jsonrpc: "2.0".to_string(),
301            method: data.method().map(|s| s.to_string()),
302            params: data.params()?,
303            result: None,
304            error: None,
305            id: self.connection.get_id(),
306        })
307    }
308
309    /// Subscribe to the topic so we can fetch messages
310    ///
311    /// This returns subscription id - not sure how it is useful
312    async fn subscribe(&self, topic: Topic) -> Result<String> {
313        self.connection.irn_subscribe(&self.topic(topic)?).await
314    }
315
316    async fn fetch_messages(&self, topic: Topic) -> Result<Vec<WcMessage>> {
317        self.connection
318            .irn_fetch_messages(&self.topic(topic)?)
319            .await?
320            .iter()
321            .map(|m| {
322                Message::decrypt(&m.message, self.sym_key(topic)?, None)
323                    .and_then(|m| m.decode())
324            })
325            .collect()
326    }
327
328    pub async fn send_message<T>(
329        &self,
330        topic: Topic,
331        message: &Message<String, T>,
332        type_byte: Option<u8>,
333        tag: IrnTag,
334        ttl: u64,
335    ) -> Result<Value>
336    where
337        T: Serialize + DeserializeOwned,
338    {
339        let cipher_text = message.encrypt(
340            self.sym_key(topic)?,
341            type_byte,
342            Some(self.public_key()),
343            None,
344        )?;
345
346        let result = self
347            .connection
348            .irn_publish(EncryptedMessage::new(
349                self.topic(topic)?,
350                cipher_text,
351                tag,
352                ttl,
353            ))
354            .await?;
355
356        Ok(result)
357    }
358
359    fn public_key(&self) -> String {
360        let secret = x25519_dalek::StaticSecret::from(self.private_key);
361        let public_key = x25519_dalek::PublicKey::from(&secret);
362        hex::encode(public_key.to_bytes())
363    }
364
365    fn other_public_key(&self) -> Result<[u8; 32]> {
366        let proposer_public_key = self
367            .proposal_request
368            .as_ref()
369            .and_then(|p| p.data.as_session_propose())
370            .map(|p| &p.proposer.public_key);
371        let auth_public_key = self
372            .authenticate_request
373            .as_ref()
374            .and_then(|p| p.data.as_session_authenticate())
375            .map(|p| &p.requester.public_key);
376        proposer_public_key
377            .or(auth_public_key)
378            .map(|k| {
379                hex::decode_to_array::<String, 32>(k.clone())
380                    .map_err(Error::from)
381            })
382            .ok_or::<Error>(
383                "other_public_key not found because pairing is not init".into(),
384            )?
385    }
386
387    pub(crate) fn participant(&self) -> Participant {
388        Participant {
389            public_key: self.public_key(),
390            metadata: self.connection.metadata().clone(),
391        }
392    }
393
394    fn sym_key(&self, topic: Topic) -> Result<[u8; 32]> {
395        Ok(match topic {
396            Topic::Initial => self.params.sym_key,
397            Topic::Response | Topic::Derived => {
398                derive_sym_key(self.private_key, self.other_public_key()?)
399            }
400        })
401    }
402
403    fn topic(&self, topic: Topic) -> Result<String> {
404        Ok(match topic {
405            // Usually topic is hash of sym key
406            Topic::Initial | Topic::Derived => {
407                hex::encode(sha256(self.sym_key(topic)?))
408            }
409            // In this specific case, the topic is not hash of sym key
410            Topic::Response => hex::encode(sha256(self.other_public_key()?)),
411        })
412    }
413
414    pub fn get_proposal(&self) -> Result<&WcMessage> {
415        self.proposal_request
416            .as_ref()
417            .ok_or("error: proposal_request is None".into())
418    }
419
420    #[allow(dead_code)]
421    pub fn get_proposal_old(
422        &self,
423        account_address: Address,
424        chain_id: u64,
425    ) -> Result<(Cacao, WcMessage, WcMessage)> {
426        if self.proposal_request.is_none()
427            || self.authenticate_request.is_none()
428        {
429            return Err("Pairing not initialised".into());
430        }
431
432        let cacao = Cacao::from_auth_request(
433            &self
434                .authenticate_request
435                .as_ref()
436                .unwrap()
437                .data
438                .as_session_authenticate()
439                .unwrap()
440                .auth_payload,
441            account_address,
442            chain_id,
443        )?;
444
445        Ok((
446            cacao,
447            // TODO is this necessary?
448            self.proposal_request.clone().unwrap(),
449            self.authenticate_request.clone().unwrap(),
450        ))
451    }
452}