Skip to content
This repository was archived by the owner on Jun 1, 2026. It is now read-only.

Commit 33b912b

Browse files
authored
Add deeplink and wallet-connect URL parsing (#1148)
1 parent 274d1a9 commit 33b912b

9 files changed

Lines changed: 371 additions & 11 deletions

File tree

crates/in_app_notifications/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ fn notification_item(
4141

4242
fn map_to_list_item(notification: &NotificationData, localizer: &LanguageLocalizer) -> CoreListItem {
4343
let id = notification.id.to_string();
44-
let url = Some(Deeplink::Rewards.to_url());
44+
let url = Some(Deeplink::Rewards { code: None }.to_gem_url());
4545

4646
match notification.notification_type {
4747
NotificationType::ReferralJoined => {

crates/primitives/src/deeplink.rs

Lines changed: 172 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,181 @@
1-
use serde::{Deserialize, Serialize};
2-
use strum::{AsRefStr, EnumString};
1+
use url::Url;
32

4-
pub const DEEP_LINK_SCHEME: &str = "gem://";
3+
use crate::AssetId;
54

6-
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, AsRefStr, EnumString)]
7-
#[strum(serialize_all = "camelCase")]
8-
#[serde(rename_all = "camelCase")]
5+
const DEEPLINK_HOST: &str = "gemwallet.com";
6+
const DEEPLINK_WEB_SCHEME: &str = "https";
7+
const DEEPLINK_GEM_SCHEME: &str = "gem";
8+
9+
const PATH_TOKENS: &str = "tokens";
10+
const PATH_PERPETUALS: &str = "perpetuals";
11+
const PATH_REWARDS: &str = "rewards";
12+
const PATH_JOIN: &str = "join";
13+
14+
const QUERY_CODE: &str = "code";
15+
16+
#[derive(Debug, Clone, PartialEq)]
917
pub enum Deeplink {
10-
Rewards,
18+
Asset { asset_id: AssetId },
19+
Perpetuals,
20+
Rewards { code: Option<String> },
1121
}
1222

1323
impl Deeplink {
1424
pub fn to_url(&self) -> String {
15-
format!("{}{}", DEEP_LINK_SCHEME, self.as_ref())
25+
format!("{DEEPLINK_WEB_SCHEME}://{DEEPLINK_HOST}{}", self.path())
26+
}
27+
28+
pub fn to_gem_url(&self) -> String {
29+
format!("{DEEPLINK_GEM_SCHEME}://{}", self.path().trim_start_matches('/'))
30+
}
31+
32+
pub fn from_url(url: &str) -> Option<Self> {
33+
let url = Url::parse(url).ok()?;
34+
let segments = url_segments(&url)?;
35+
let (component, params) = segments.split_first()?;
36+
37+
let deeplink = match component.as_str() {
38+
PATH_TOKENS => Deeplink::Asset {
39+
asset_id: AssetId::from(params.first()?.parse().ok()?, params.get(1).cloned()),
40+
},
41+
PATH_PERPETUALS => Deeplink::Perpetuals,
42+
PATH_REWARDS | PATH_JOIN => Deeplink::Rewards {
43+
code: params.first().cloned().or_else(|| query_value(&url, QUERY_CODE)),
44+
},
45+
_ => return None,
46+
};
47+
Some(deeplink)
48+
}
49+
50+
fn path(&self) -> String {
51+
match self {
52+
Deeplink::Asset { asset_id } => match &asset_id.token_id {
53+
Some(token_id) => format!("/{PATH_TOKENS}/{}/{token_id}", asset_id.chain.as_ref()),
54+
None => format!("/{PATH_TOKENS}/{}", asset_id.chain.as_ref()),
55+
},
56+
Deeplink::Perpetuals => format!("/{PATH_PERPETUALS}"),
57+
Deeplink::Rewards { code } => path_with_query(PATH_REWARDS, QUERY_CODE, code.clone()),
58+
}
59+
}
60+
}
61+
62+
fn path_with_query(component: &str, query_key: &str, query_value: Option<String>) -> String {
63+
match query_value {
64+
Some(value) => format!("/{component}?{query_key}={value}"),
65+
None => format!("/{component}"),
66+
}
67+
}
68+
69+
fn url_segments(url: &Url) -> Option<Vec<String>> {
70+
let mut segments: Vec<String> = url
71+
.path_segments()
72+
.map(|parts| parts.filter(|part| !part.is_empty()).map(String::from).collect())
73+
.unwrap_or_default();
74+
75+
match url.scheme() {
76+
DEEPLINK_WEB_SCHEME => {
77+
if url.host_str()? != DEEPLINK_HOST {
78+
return None;
79+
}
80+
}
81+
DEEPLINK_GEM_SCHEME => {
82+
if let Some(host) = url.host_str().filter(|host| !host.is_empty()) {
83+
segments.insert(0, host.to_string());
84+
}
85+
}
86+
_ => return None,
87+
}
88+
Some(segments)
89+
}
90+
91+
fn query_value(url: &Url, key: &str) -> Option<String> {
92+
url.query_pairs().find(|(query_key, _)| query_key.as_ref() == key).map(|(_, value)| value.into_owned())
93+
}
94+
95+
#[cfg(test)]
96+
mod tests {
97+
use super::*;
98+
use crate::Chain;
99+
100+
#[test]
101+
fn test_to_url() {
102+
assert_eq!(
103+
Deeplink::Asset {
104+
asset_id: AssetId::from_chain(Chain::Bitcoin)
105+
}
106+
.to_url(),
107+
"https://gemwallet.com/tokens/bitcoin"
108+
);
109+
assert_eq!(
110+
Deeplink::Asset {
111+
asset_id: AssetId::token(Chain::Ethereum, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
112+
}
113+
.to_url(),
114+
"https://gemwallet.com/tokens/ethereum/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
115+
);
116+
assert_eq!(Deeplink::Perpetuals.to_url(), "https://gemwallet.com/perpetuals");
117+
assert_eq!(Deeplink::Rewards { code: None }.to_url(), "https://gemwallet.com/rewards");
118+
assert_eq!(
119+
Deeplink::Rewards {
120+
code: Some("gemcoder".to_string()),
121+
}
122+
.to_url(),
123+
"https://gemwallet.com/rewards?code=gemcoder"
124+
);
125+
}
126+
127+
#[test]
128+
fn test_to_gem_url() {
129+
assert_eq!(Deeplink::Rewards { code: None }.to_gem_url(), "gem://rewards");
130+
assert_eq!(Deeplink::Perpetuals.to_gem_url(), "gem://perpetuals");
131+
assert_eq!(
132+
Deeplink::Asset {
133+
asset_id: AssetId::from_chain(Chain::Bitcoin)
134+
}
135+
.to_gem_url(),
136+
"gem://tokens/bitcoin"
137+
);
138+
}
139+
140+
#[test]
141+
fn test_from_url() {
142+
assert_eq!(
143+
Deeplink::from_url("https://gemwallet.com/tokens/bitcoin"),
144+
Some(Deeplink::Asset {
145+
asset_id: AssetId::from_chain(Chain::Bitcoin)
146+
})
147+
);
148+
assert_eq!(
149+
Deeplink::from_url("https://gemwallet.com/tokens/ethereum/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
150+
Some(Deeplink::Asset {
151+
asset_id: AssetId::token(Chain::Ethereum, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
152+
})
153+
);
154+
assert_eq!(
155+
Deeplink::from_url("gem://tokens/bitcoin"),
156+
Some(Deeplink::Asset {
157+
asset_id: AssetId::from_chain(Chain::Bitcoin)
158+
})
159+
);
160+
assert_eq!(Deeplink::from_url("https://gemwallet.com/perpetuals"), Some(Deeplink::Perpetuals));
161+
assert_eq!(Deeplink::from_url("gem://perpetuals"), Some(Deeplink::Perpetuals));
162+
assert_eq!(
163+
Deeplink::from_url("https://gemwallet.com/rewards?code=gemcoder"),
164+
Some(Deeplink::Rewards {
165+
code: Some("gemcoder".to_string()),
166+
})
167+
);
168+
assert_eq!(
169+
Deeplink::from_url("https://gemwallet.com/join/gemcoder"),
170+
Some(Deeplink::Rewards {
171+
code: Some("gemcoder".to_string()),
172+
})
173+
);
174+
assert_eq!(Deeplink::from_url("https://gemwallet.com/join"), Some(Deeplink::Rewards { code: None }));
175+
assert_eq!(Deeplink::from_url("https://gemwallet.com/tokens"), None);
176+
assert_eq!(Deeplink::from_url("https://gemwallet.com/tokens/notachain"), None);
177+
assert_eq!(Deeplink::from_url("https://example.com/tokens/bitcoin"), None);
178+
assert_eq!(Deeplink::from_url("https://gemwallet.com/unknown"), None);
179+
assert_eq!(Deeplink::from_url("not a url"), None);
16180
}
17181
}

crates/primitives/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ pub use self::transaction_metadata_types::{
160160
pub mod wallet_connect_namespace;
161161
pub use self::wallet_connect_namespace::WalletConnectCAIP2;
162162
pub mod wallet_connect;
163-
pub use self::wallet_connect::{WCEthereumTransaction, WCTonMessage, WalletConnectRequest};
163+
pub use self::wallet_connect::{WCEthereumTransaction, WCTonMessage, WalletConnectLink, WalletConnectRequest};
164164
pub mod account;
165165
pub use self::account::Account;
166166
pub mod wallet;
@@ -306,6 +306,8 @@ pub mod notification_data;
306306
pub use self::notification_data::{NotificationData, NotificationRewardsMetadata, NotificationRewardsRedeemMetadata};
307307
pub mod deeplink;
308308
pub use self::deeplink::Deeplink;
309+
pub mod url_action;
310+
pub use self::url_action::UrlAction;
309311
pub mod list_item;
310312
pub use self::list_item::{CoreEmoji, CoreListItem, CoreListItemBadge, CoreListItemIcon};
311313
pub mod notification;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
use crate::{Deeplink, WalletConnectLink};
2+
3+
#[derive(Debug, Clone, PartialEq)]
4+
pub enum UrlAction {
5+
Deeplink { deeplink: Deeplink },
6+
WalletConnect { link: WalletConnectLink },
7+
}
8+
9+
impl UrlAction {
10+
pub fn from_url(url: &str) -> Option<Self> {
11+
if let Some(link) = WalletConnectLink::from_url(url) {
12+
return Some(Self::WalletConnect { link });
13+
}
14+
Deeplink::from_url(url).map(|deeplink| Self::Deeplink { deeplink })
15+
}
16+
}
17+
18+
#[cfg(test)]
19+
mod tests {
20+
use super::*;
21+
use crate::{AssetId, Chain};
22+
23+
#[test]
24+
fn test_from_url() {
25+
assert_eq!(
26+
UrlAction::from_url("https://gemwallet.com/tokens/bitcoin"),
27+
Some(UrlAction::Deeplink {
28+
deeplink: Deeplink::Asset {
29+
asset_id: AssetId::from_chain(Chain::Bitcoin),
30+
},
31+
})
32+
);
33+
assert_eq!(
34+
UrlAction::from_url("gem://wc?sessionTopic=abc123"),
35+
Some(UrlAction::WalletConnect {
36+
link: WalletConnectLink::Session { topic: "abc123".to_string() },
37+
})
38+
);
39+
assert_eq!(
40+
UrlAction::from_url("wc:topic@2?relay-protocol=irn&symKey=abc"),
41+
Some(UrlAction::WalletConnect {
42+
link: WalletConnectLink::Connect {
43+
uri: "wc:topic@2?relay-protocol=irn&symKey=abc".to_string(),
44+
},
45+
})
46+
);
47+
assert_eq!(UrlAction::from_url("https://example.com/tokens/bitcoin"), None);
48+
assert_eq!(UrlAction::from_url("not a url"), None);
49+
}
50+
}

crates/primitives/src/wallet_connect.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
use serde::{Deserialize, Serialize};
22
use typeshare::typeshare;
3+
use url::Url;
4+
5+
const WALLET_CONNECT_SCHEME: &str = "wc";
6+
const WALLET_CONNECT_HOST: &str = "wc";
7+
const GEM_SCHEME: &str = "gem";
8+
9+
const QUERY_URI: &str = "uri";
10+
const QUERY_SESSION_TOPIC: &str = "sessionTopic";
11+
const QUERY_REQUEST_ID: &str = "requestId";
312

413
#[derive(Debug, Serialize, Deserialize)]
514
#[typeshare(swift = "Equatable, Hashable, Sendable")]
@@ -37,3 +46,73 @@ pub struct WalletConnectRequest {
3746
pub chain_id: Option<String>,
3847
pub domain: String,
3948
}
49+
50+
#[derive(Debug, Clone, PartialEq)]
51+
pub enum WalletConnectLink {
52+
Connect { uri: String },
53+
Request,
54+
Session { topic: String },
55+
}
56+
57+
impl WalletConnectLink {
58+
pub fn from_url(url: &str) -> Option<Self> {
59+
let parsed = Url::parse(url).ok()?;
60+
match parsed.scheme() {
61+
WALLET_CONNECT_SCHEME => Some(Self::session_or_request(&parsed).unwrap_or_else(|| WalletConnectLink::Connect { uri: url.to_string() })),
62+
GEM_SCHEME if parsed.host_str() == Some(WALLET_CONNECT_HOST) => match query_value(&parsed, QUERY_URI).filter(|uri| !uri.is_empty()) {
63+
Some(uri) => Some(WalletConnectLink::Connect { uri }),
64+
None => Self::session_or_request(&parsed),
65+
},
66+
_ => None,
67+
}
68+
}
69+
70+
fn session_or_request(url: &Url) -> Option<Self> {
71+
if let Some(topic) = query_value(url, QUERY_SESSION_TOPIC).filter(|topic| !topic.is_empty()) {
72+
Some(WalletConnectLink::Session { topic })
73+
} else if query_value(url, QUERY_REQUEST_ID).is_some() {
74+
Some(WalletConnectLink::Request)
75+
} else {
76+
None
77+
}
78+
}
79+
}
80+
81+
fn query_value(url: &Url, key: &str) -> Option<String> {
82+
url.query_pairs().find(|(query_key, _)| query_key.as_ref() == key).map(|(_, value)| value.into_owned())
83+
}
84+
85+
#[cfg(test)]
86+
mod tests {
87+
use super::*;
88+
89+
#[test]
90+
fn test_wallet_connect_link_from_url() {
91+
assert_eq!(
92+
WalletConnectLink::from_url("wc:abc@2?relay-protocol=irn&symKey=123"),
93+
Some(WalletConnectLink::Connect {
94+
uri: "wc:abc@2?relay-protocol=irn&symKey=123".to_string(),
95+
})
96+
);
97+
assert_eq!(WalletConnectLink::from_url("wc:abc@2?requestId"), Some(WalletConnectLink::Request));
98+
assert_eq!(WalletConnectLink::from_url("wc:abc@2?requestId=123"), Some(WalletConnectLink::Request));
99+
assert_eq!(
100+
WalletConnectLink::from_url("gem://wc?uri=wc:topic@2"),
101+
Some(WalletConnectLink::Connect { uri: "wc:topic@2".to_string() })
102+
);
103+
assert_eq!(
104+
WalletConnectLink::from_url("gem://wc?uri=wc%3Atopic%402%3Frelay-protocol%3Dirn%26symKey%3Dabc"),
105+
Some(WalletConnectLink::Connect {
106+
uri: "wc:topic@2?relay-protocol=irn&symKey=abc".to_string(),
107+
})
108+
);
109+
assert_eq!(WalletConnectLink::from_url("gem://wc?requestId=1"), Some(WalletConnectLink::Request));
110+
assert_eq!(
111+
WalletConnectLink::from_url("gem://wc?sessionTopic=abc123"),
112+
Some(WalletConnectLink::Session { topic: "abc123".to_string() })
113+
);
114+
assert_eq!(WalletConnectLink::from_url("gem://wc?sessionTopic="), None);
115+
assert_eq!(WalletConnectLink::from_url("gem://asset/solana"), None);
116+
assert_eq!(WalletConnectLink::from_url("https://gemwallet.com/tokens/bitcoin"), None);
117+
}
118+
}

gemstone/src/deeplink.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
use primitives::{AssetId, Deeplink};
2+
3+
#[uniffi::remote(Enum)]
4+
pub enum Deeplink {
5+
Asset { asset_id: AssetId },
6+
Perpetuals,
7+
Rewards { code: Option<String> },
8+
}
9+
10+
#[uniffi::export]
11+
pub fn deeplink_build_url(deeplink: Deeplink) -> String {
12+
deeplink.to_url()
13+
}
14+
15+
#[uniffi::export]
16+
pub fn deeplink_build_gem_url(deeplink: Deeplink) -> String {
17+
deeplink.to_gem_url()
18+
}
19+
20+
#[cfg(test)]
21+
mod tests {
22+
use super::*;
23+
24+
#[test]
25+
fn test_deeplink() {
26+
let rewards = Deeplink::Rewards {
27+
code: Some("gemcoder".to_string()),
28+
};
29+
assert_eq!(deeplink_build_url(rewards), "https://gemwallet.com/rewards?code=gemcoder");
30+
assert_eq!(deeplink_build_gem_url(Deeplink::Perpetuals), "gem://perpetuals");
31+
}
32+
}

0 commit comments

Comments
 (0)