df_hyperliquid_core/account/
agent.rs1use std::fmt;
6
7use crate::account::Signature;
8use crate::client::{ApiError, Network, Transport, body, info, user_request};
9use crate::utils::Address;
10use crate::utils::sign::{Field, Map, NonceKey, UserAction, UserSigned, Value};
11use serde::Deserialize;
12
13pub const MAX_AGENT_VALIDITY_MS: u64 = 180 * 86_400_000;
15
16pub const LONGEST_AGENT_NAME: usize = 16;
18
19const VALID_UNTIL: &str = " valid_until ";
21
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub struct AgentName(String);
26
27#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28#[error("an agent's name is 1 to {LONGEST_AGENT_NAME} characters and not a valid_until form, which `{0}` is not")]
29pub struct BadAgentName(pub String);
30
31impl AgentName {
32 pub fn new(name: &str) -> Result<AgentName, BadAgentName> {
33 let fits = !name.trim().is_empty() && name.chars().count() <= LONGEST_AGENT_NAME;
34 match fits && !name.contains("valid_until") {
35 true => Ok(AgentName(name.to_owned())),
36 false => Err(BadAgentName(name.to_owned())),
37 }
38 }
39
40 #[must_use]
41 pub fn as_str(&self) -> &str {
42 &self.0
43 }
44}
45
46impl fmt::Display for AgentName {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 f.write_str(&self.0)
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
54#[error("an agent's approval ends ahead of now ({now}) and within 180 days, which {until} does not")]
55pub struct BadExpiry {
56 pub until: u64,
57 pub now: u64,
58}
59
60#[must_use]
63pub fn split_valid_until(name: &str) -> (&str, Option<u64>) {
64 match name.rsplit_once(VALID_UNTIL) {
65 Some((base, ms)) if !ms.is_empty() && ms.bytes().all(|b| b.is_ascii_digit()) => (base, ms.parse().ok()),
66 _ => (name, None),
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ApproveAgent {
75 agent: Address,
76 name: String,
77 null: bool,
79 nonce: u64,
80}
81
82impl ApproveAgent {
83 #[must_use]
85 pub fn new(agent: Address, name: &str, nonce: u64) -> ApproveAgent {
86 ApproveAgent { agent, name: name.to_owned(), null: false, nonce }
87 }
88
89 #[must_use]
92 pub const fn unnamed(agent: Address, nonce: u64) -> ApproveAgent {
93 ApproveAgent { agent, name: String::new(), null: true, nonce }
94 }
95
96 #[must_use]
98 pub fn named(agent: Address, name: &AgentName, nonce: u64) -> ApproveAgent {
99 ApproveAgent::new(agent, name.as_str(), nonce)
100 }
101
102 pub fn expiring(
105 agent: Address,
106 name: &AgentName,
107 until: u64,
108 now: u64,
109 nonce: u64,
110 ) -> Result<ApproveAgent, BadExpiry> {
111 let ahead = until.checked_sub(now).filter(|ahead| *ahead > 0 && *ahead <= MAX_AGENT_VALIDITY_MS);
112 match ahead {
113 Some(_) => Ok(ApproveAgent::new(agent, &format!("{name}{VALID_UNTIL}{until}"), nonce)),
114 None => Err(BadExpiry { until, now }),
115 }
116 }
117
118 #[must_use]
119 pub const fn agent(&self) -> Address {
120 self.agent
121 }
122
123 #[must_use]
125 pub fn name(&self) -> Option<&str> {
126 Some(self.name.as_str()).filter(|name| !name.is_empty())
127 }
128}
129
130impl UserAction for ApproveAgent {
131 fn user_signed(&self) -> UserSigned<'_> {
132 UserSigned::new("approveAgent", "HyperliquidTransaction:ApproveAgent", NonceKey::Nonce, self.nonce)
133 .field("agentAddress", Field::Address(self.agent))
134 .field("agentName", Field::String(&self.name))
135 }
136
137 fn action(&self, network: Network) -> Map {
138 let action = self.user_signed().action(network);
139 if !self.null {
140 return action;
141 }
142 action.into_pairs().fold(Map::new(), |map, (key, value)| match key == "agentName" {
143 true => map.put(key, Value::Nil),
144 false => map.put(key, value),
145 })
146 }
147
148 fn wire(&self, network: Network) -> String {
149 self.action(network).to_json().compact()
150 }
151
152 fn body(&self, signature: &Signature, network: Network) -> String {
153 body::user_signed(&self.action(network), self.nonce, signature)
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
159pub enum Reused {
160 #[error("this address is one of the account's agents already")]
161 Listed,
162 #[error("this address was an agent before, and what it signed then could be replayed")]
163 Retired,
164 #[error("the venue knows this address already, as {0:?}; an agent is a fresh address")]
166 Known(Role),
167}
168
169pub fn fresh_agent(candidate: Address, role: &Role, listed: &[Agent], retired: &[Address]) -> Result<(), Reused> {
173 if listed.iter().any(|agent| agent.address == candidate) {
174 return Err(Reused::Listed);
175 }
176 if retired.contains(&candidate) {
177 return Err(Reused::Retired);
178 }
179 match role {
180 Role::Missing => Ok(()),
181 known => Err(Reused::Known(known.clone())),
182 }
183}
184
185#[must_use]
187pub fn extra_agents_request(user: Address) -> String {
188 user_request("extraAgents", user)
189}
190
191pub async fn extra_agents(transport: &impl Transport, user: Address) -> Result<Vec<Agent>, ApiError> {
194 let agents: Option<Vec<Agent>> = info(transport, extra_agents_request(user)).await?;
195 Ok(agents.unwrap_or_default())
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct Agent {
201 #[serde(default)]
202 pub name: String,
203 pub address: Address,
204 #[serde(default)]
206 pub valid_until: Option<u64>,
207}
208
209impl Agent {
210 #[must_use]
212 pub fn is_live(&self, now: u64) -> bool {
213 self.valid_until.is_none_or(|until| until > now)
214 }
215}
216
217#[must_use]
219pub fn role_request(user: Address) -> String {
220 user_request("userRole", user)
221}
222
223pub async fn role(transport: &impl Transport, user: Address) -> Result<Role, ApiError> {
224 info(transport, role_request(user)).await
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
229#[serde(from = "RawRole")]
230pub enum Role {
231 User,
232 Missing,
234 Vault,
235 Agent {
237 user: Address,
238 },
239 SubAccount {
241 master: Address,
242 },
243 Other(String),
246}
247
248impl Role {
249 #[must_use]
251 pub const fn agent_of(&self) -> Option<Address> {
252 match self {
253 Role::Agent { user } => Some(*user),
254 _ => None,
255 }
256 }
257
258 #[must_use]
260 pub const fn master(&self) -> Option<Address> {
261 match self {
262 Role::SubAccount { master } => Some(*master),
263 _ => None,
264 }
265 }
266}
267
268#[derive(Deserialize)]
269struct RawRole {
270 #[serde(default)]
271 role: String,
272 #[serde(default)]
273 data: Option<RawRoleData>,
274}
275
276#[derive(Deserialize)]
277struct RawRoleData {
278 #[serde(default)]
279 user: Option<String>,
280 #[serde(default)]
281 master: Option<String>,
282}
283
284impl From<RawRole> for Role {
285 fn from(raw: RawRole) -> Role {
286 let named = |pick: fn(&RawRoleData) -> Option<&String>| -> Option<Address> {
287 raw.data.as_ref().and_then(pick).and_then(|text| text.parse().ok())
288 };
289 match raw.role.as_str() {
290 "user" => Role::User,
291 "missing" => Role::Missing,
292 "vault" => Role::Vault,
293 "agent" => {
294 named(|data| data.user.as_ref()).map_or(Role::Other(raw.role.clone()), |user| Role::Agent { user })
295 }
296 "subAccount" => named(|data| data.master.as_ref())
297 .map_or(Role::Other(raw.role.clone()), |master| Role::SubAccount { master }),
298 _ => Role::Other(raw.role.clone()),
299 }
300 }
301}
302
303#[cfg(test)]
304#[path = "tests/agent.rs"]
305mod tests;