Skip to main content

df_hyperliquid_core/account/
agent.rs

1//! API wallets (agents): `approveAgent`, an account's named agents (`extraAgents`) and what the
2//! venue knows an address as (`userRole`). An agent signs L1 actions for its master and never
3//! moves funds; there is no revoke, so an address that was an agent is never approved again.
4
5use 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
13/// The longest an approval may run: 180 days ahead.
14pub const MAX_AGENT_VALIDITY_MS: u64 = 180 * 86_400_000;
15
16/// The longest agent name, in characters.
17pub const LONGEST_AGENT_NAME: usize = 16;
18
19/// What sets an expiry inside a name.
20const VALID_UNTIL: &str = " valid_until ";
21
22/// A named agent's name: 1 to 16 characters, not blank, and never the
23/// `valid_until` form, which the venue reads as an expiry.
24#[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/// An expiry that does not lie ahead of `now`, or lies more than 180 days ahead.
53#[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/// `<name> valid_until <ms>` back into the name and the expiry. A name
61/// without the form is itself, with none.
62#[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/// `approveAgent`, signed by the account's own wallet. There is no revoke: approving a name again
71/// replaces that agent. Never approve an address that was an agent before, since what it signed
72/// then can be replayed.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ApproveAgent {
75    agent: Address,
76    name: String,
77    /// The unnamed agent, posted with `agentName` null.
78    null: bool,
79    nonce: u64,
80}
81
82impl ApproveAgent {
83    /// Under `name` as given. Empty approves the unnamed agent, signed and posted as the empty string.
84    #[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    /// The unnamed agent, posted with `agentName` null and signed as the empty string: the digest is
90    /// the empty name's, only the posted JSON differs.
91    #[must_use]
92    pub const fn unnamed(agent: Address, nonce: u64) -> ApproveAgent {
93        ApproveAgent { agent, name: String::new(), null: true, nonce }
94    }
95
96    /// A named agent that never lapses.
97    #[must_use]
98    pub fn named(agent: Address, name: &AgentName, nonce: u64) -> ApproveAgent {
99        ApproveAgent::new(agent, name.as_str(), nonce)
100    }
101
102    /// A named agent until `until` milliseconds: `<name> valid_until <until>`,
103    /// refused unless it lies ahead of `now` and at most 180 days ahead.
104    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    /// `agentName` as signed; `None` for the unnamed agent.
124    #[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/// Why an address may not be approved as an agent.
158#[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    /// The venue knows the address already, as this.
165    #[error("the venue knows this address already, as {0:?}; an agent is a fresh address")]
166    Known(Role),
167}
168
169/// Whether `candidate` may be approved as an agent: an address the venue has
170/// never seen (`userRole` answers `missing`), not among the account's agents
171/// (`extraAgents`) and not one it retired, which only the caller remembers.
172pub 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/// `{"type":"extraAgents","user":..}`.
186#[must_use]
187pub fn extra_agents_request(user: Address) -> String {
188    user_request("extraAgents", user)
189}
190
191/// The account's named agents. The venue has answered both `null` and `[]`
192/// for an account with none; both read as none.
193pub 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    /// Milliseconds; from it on the agent signs nothing.
205    #[serde(default)]
206    pub valid_until: Option<u64>,
207}
208
209impl Agent {
210    /// Whether it still signs at `now` milliseconds.
211    #[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/// `{"type":"userRole","user":..}`.
218#[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/// What the venue knows an address as.
228#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
229#[serde(from = "RawRole")]
230pub enum Role {
231    User,
232    /// An address the venue has never seen.
233    Missing,
234    Vault,
235    /// An agent, and the account it signs for.
236    Agent {
237        user: Address,
238    },
239    /// A subaccount, and its master.
240    SubAccount {
241        master: Address,
242    },
243    /// A role this build does not know, or a known one whose data did not
244    /// read, kept as spelled.
245    Other(String),
246}
247
248impl Role {
249    /// The account an agent signs for.
250    #[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    /// A subaccount's master.
259    #[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;